wip(TRUEREF-0018): commit version-scoped indexing work

This commit is contained in:
Giancarmine Salucci
2026-03-25 19:03:22 +01:00
parent b9d52405fa
commit fef6f66930
21 changed files with 1208 additions and 19 deletions

View File

@@ -49,6 +49,7 @@ function makeVersion(tag: string): RepositoryVersion {
repositoryId: '/facebook/react',
tag,
title: null,
commitHash: null,
state: 'indexed',
totalSnippets: 100,
indexedAt: new Date(),

View File

@@ -0,0 +1 @@
ALTER TABLE `repository_versions` ADD `commit_hash` text;

View File

@@ -0,0 +1,746 @@
{
"version": "6",
"dialect": "sqlite",
"id": "60c9a1b5-449f-45fd-9b2d-1ab4cca78ab6",
"prevId": "9dec55ea-0c03-4c98-99a6-dd143b336791",
"tables": {
"documents": {
"name": "documents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version_id": {
"name": "version_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_count": {
"name": "token_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"indexed_at": {
"name": "indexed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"documents_repository_id_repositories_id_fk": {
"name": "documents_repository_id_repositories_id_fk",
"tableFrom": "documents",
"tableTo": "repositories",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"documents_version_id_repository_versions_id_fk": {
"name": "documents_version_id_repository_versions_id_fk",
"tableFrom": "documents",
"tableTo": "repository_versions",
"columnsFrom": [
"version_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"indexing_jobs": {
"name": "indexing_jobs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version_id": {
"name": "version_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'queued'"
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"total_files": {
"name": "total_files",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"processed_files": {
"name": "processed_files",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"indexing_jobs_repository_id_repositories_id_fk": {
"name": "indexing_jobs_repository_id_repositories_id_fk",
"tableFrom": "indexing_jobs",
"tableTo": "repositories",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repositories": {
"name": "repositories",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"branch": {
"name": "branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'main'"
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"total_snippets": {
"name": "total_snippets",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"total_tokens": {
"name": "total_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"trust_score": {
"name": "trust_score",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"benchmark_score": {
"name": "benchmark_score",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"stars": {
"name": "stars",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_token": {
"name": "github_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_indexed_at": {
"name": "last_indexed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repository_configs": {
"name": "repository_configs",
"columns": {
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_title": {
"name": "project_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"folders": {
"name": "folders",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exclude_folders": {
"name": "exclude_folders",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exclude_files": {
"name": "exclude_files",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rules": {
"name": "rules",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"previous_versions": {
"name": "previous_versions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"repository_configs_repository_id_repositories_id_fk": {
"name": "repository_configs_repository_id_repositories_id_fk",
"tableFrom": "repository_configs",
"tableTo": "repositories",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repository_versions": {
"name": "repository_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag": {
"name": "tag",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"total_snippets": {
"name": "total_snippets",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"indexed_at": {
"name": "indexed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"repository_versions_repository_id_repositories_id_fk": {
"name": "repository_versions_repository_id_repositories_id_fk",
"tableFrom": "repository_versions",
"tableTo": "repositories",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snippet_embeddings": {
"name": "snippet_embeddings",
"columns": {
"snippet_id": {
"name": "snippet_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dimensions": {
"name": "dimensions",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"embedding": {
"name": "embedding",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"snippet_embeddings_snippet_id_snippets_id_fk": {
"name": "snippet_embeddings_snippet_id_snippets_id_fk",
"tableFrom": "snippet_embeddings",
"tableTo": "snippets",
"columnsFrom": [
"snippet_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snippets": {
"name": "snippets",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"document_id": {
"name": "document_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version_id": {
"name": "version_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"breadcrumb": {
"name": "breadcrumb",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_count": {
"name": "token_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"snippets_document_id_documents_id_fk": {
"name": "snippets_document_id_documents_id_fk",
"tableFrom": "snippets",
"tableTo": "documents",
"columnsFrom": [
"document_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"snippets_repository_id_repositories_id_fk": {
"name": "snippets_repository_id_repositories_id_fk",
"tableFrom": "snippets",
"tableTo": "repositories",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"snippets_version_id_repository_versions_id_fk": {
"name": "snippets_version_id_repository_versions_id_fk",
"tableFrom": "snippets",
"tableTo": "repository_versions",
"columnsFrom": [
"version_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1774196053634,
"tag": "0000_large_master_chief",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1774448049161,
"tag": "0001_quick_nighthawk",
"breakpoints": true
}
]
}

View File

@@ -37,6 +37,7 @@ export const repositoryVersions = sqliteTable('repository_versions', {
.references(() => repositories.id, { onDelete: 'cascade' }),
tag: text('tag').notNull(), // git tag or branch name
title: text('title'),
commitHash: text('commit_hash'), // immutable commit SHA-1 resolved from tag
state: text('state', {
enum: ['pending', 'indexing', 'indexed', 'error']
})
@@ -135,7 +136,7 @@ export const repositoryConfigs = sqliteTable('repository_configs', {
excludeFiles: text('exclude_files', { mode: 'json' }).$type<string[]>(),
rules: text('rules', { mode: 'json' }).$type<string[]>(),
previousVersions: text('previous_versions', { mode: 'json' }).$type<
{ tag: string; title: string }[]
{ tag: string; title: string; commitHash?: string }[]
>(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
});

View File

@@ -11,6 +11,7 @@ export class RepositoryVersionMapper {
repositoryId: entity.repository_id,
tag: entity.tag,
title: entity.title,
commitHash: entity.commit_hash,
state: entity.state,
totalSnippets: entity.total_snippets ?? 0,
indexedAt: entity.indexed_at != null ? new Date(entity.indexed_at * 1000) : null,
@@ -24,6 +25,7 @@ export class RepositoryVersionMapper {
repositoryId: domain.repositoryId,
tag: domain.tag,
title: domain.title,
commitHash: domain.commitHash,
state: domain.state,
totalSnippets: domain.totalSnippets,
indexedAt: domain.indexedAt,

View File

@@ -3,6 +3,7 @@ export interface RepositoryVersionEntityProps {
repository_id: string;
tag: string;
title: string | null;
commit_hash: string | null;
state: 'pending' | 'indexing' | 'indexed' | 'error';
total_snippets: number | null;
indexed_at: number | null;
@@ -14,6 +15,7 @@ export class RepositoryVersionEntity {
repository_id: string;
tag: string;
title: string | null;
commit_hash: string | null;
state: 'pending' | 'indexing' | 'indexed' | 'error';
total_snippets: number | null;
indexed_at: number | null;
@@ -24,6 +26,7 @@ export class RepositoryVersionEntity {
this.repository_id = props.repository_id;
this.tag = props.tag;
this.title = props.title;
this.commit_hash = props.commit_hash;
this.state = props.state;
this.total_snippets = props.total_snippets;
this.indexed_at = props.indexed_at;
@@ -36,6 +39,7 @@ export interface RepositoryVersionProps {
repositoryId: string;
tag: string;
title: string | null;
commitHash: string | null;
state: 'pending' | 'indexing' | 'indexed' | 'error';
totalSnippets: number;
indexedAt: Date | null;
@@ -47,6 +51,7 @@ export class RepositoryVersion {
repositoryId: string;
tag: string;
title: string | null;
commitHash: string | null;
state: 'pending' | 'indexing' | 'indexed' | 'error';
totalSnippets: number;
indexedAt: Date | null;
@@ -57,6 +62,7 @@ export class RepositoryVersion {
this.repositoryId = props.repositoryId;
this.tag = props.tag;
this.title = props.title;
this.commitHash = props.commitHash;
this.state = props.state;
this.totalSnippets = props.totalSnippets;
this.indexedAt = props.indexedAt;
@@ -69,6 +75,7 @@ export interface RepositoryVersionDtoProps {
repositoryId: string;
tag: string;
title: string | null;
commitHash: string | null;
state: 'pending' | 'indexing' | 'indexed' | 'error';
totalSnippets: number;
indexedAt: Date | null;
@@ -80,6 +87,7 @@ export class RepositoryVersionDto {
repositoryId: string;
tag: string;
title: string | null;
commitHash: string | null;
state: 'pending' | 'indexing' | 'indexed' | 'error';
totalSnippets: number;
indexedAt: Date | null;
@@ -90,6 +98,7 @@ export class RepositoryVersionDto {
this.repositoryId = props.repositoryId;
this.tag = props.tag;
this.title = props.title;
this.commitHash = props.commitHash;
this.state = props.state;
this.totalSnippets = props.totalSnippets;
this.indexedAt = props.indexedAt;

View File

@@ -595,8 +595,7 @@ describe('formatLibraryResults', () => {
id: '/facebook/react/v18',
repositoryId: '/facebook/react',
tag: 'v18',
title: 'React 18',
state: 'indexed',
title: 'React 18', commitHash: null, state: 'indexed',
totalSnippets: 1000,
indexedAt: null,
createdAt: now

View File

@@ -23,17 +23,34 @@ function createTestDb(): Database.Database {
client.pragma('foreign_keys = ON');
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
const migrationSql = readFileSync(
// Apply all migration files in order
const migration0 = readFileSync(
join(migrationsFolder, '0000_large_master_chief.sql'),
'utf-8'
);
const migration1 = readFileSync(
join(migrationsFolder, '0001_quick_nighthawk.sql'),
'utf-8'
);
const statements = migrationSql
// Apply first migration
const statements0 = migration0
.split('--> statement-breakpoint')
.map((s) => s.trim())
.filter(Boolean);
for (const stmt of statements) {
for (const stmt of statements0) {
client.exec(stmt);
}
// Apply second migration
const statements1 = migration1
.split('--> statement-breakpoint')
.map((s) => s.trim())
.filter(Boolean);
for (const stmt of statements1) {
client.exec(stmt);
}

View File

@@ -34,14 +34,22 @@ export class VersionService {
* Add a new version record for a repository.
* The version ID follows the convention: {repositoryId}/{tag}
*
* @param commitHash Optional commit hash. If not provided and repository is local,
* will attempt to resolve the tag to a commit hash automatically.
*
* @throws NotFoundError when the parent repository does not exist
* @throws AlreadyExistsError when the tag is already registered
*/
add(repositoryId: string, tag: string, title?: string): RepositoryVersion {
add(
repositoryId: string,
tag: string,
title?: string,
commitHash?: string
): RepositoryVersion {
// Verify parent repository exists.
const repo = this.db
.prepare(`SELECT id FROM repositories WHERE id = ?`)
.get(repositoryId) as { id: string } | undefined;
.prepare(`SELECT id, source, source_url FROM repositories WHERE id = ?`)
.get(repositoryId) as { id: string; source: string; source_url: string } | undefined;
if (!repo) {
throw new NotFoundError(`Repository ${repositoryId} not found`);
@@ -55,15 +63,29 @@ export class VersionService {
throw new AlreadyExistsError(`Version ${tag} already exists for repository ${repositoryId}`);
}
// For local repositories, attempt to resolve tag to commit hash if not provided
let resolvedCommitHash = commitHash;
if (!resolvedCommitHash && repo.source === 'local') {
try {
const { resolveTagToCommit } = require('$lib/server/utils/git.js');
resolvedCommitHash = resolveTagToCommit({ repoPath: repo.source_url, tag });
} catch (error) {
console.warn(
`[VersionService] Could not resolve tag '${tag}' to commit hash for ${repositoryId}: ${error instanceof Error ? error.message : String(error)}`
);
// Continue without commit hash — non-blocking
}
}
const now = Math.floor(Date.now() / 1000);
this.db
.prepare(
`INSERT INTO repository_versions
(id, repository_id, tag, title, state, total_snippets, indexed_at, created_at)
VALUES (?, ?, ?, ?, 'pending', 0, NULL, ?)`
(id, repository_id, tag, title, commit_hash, state, total_snippets, indexed_at, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', 0, NULL, ?)`
)
.run(id, repositoryId, tag, title ?? null, now);
.run(id, repositoryId, tag, title ?? null, resolvedCommitHash ?? null, now);
const row = this.db
.prepare(`SELECT * FROM repository_versions WHERE id = ?`)
@@ -105,11 +127,14 @@ export class VersionService {
* Silently skips tags that are already registered (idempotent).
* All new records are created with state = 'pending'.
*
* Supports optional `commitHash` field to pin a version to a specific commit,
* overriding tag resolution (TRUEREF-0019).
*
* @throws NotFoundError when the parent repository does not exist
*/
registerFromConfig(
repositoryId: string,
previousVersions: { tag: string; title: string }[]
previousVersions: { tag: string; title: string; commitHash?: string }[]
): RepositoryVersion[] {
// Verify parent repository exists.
const repo = this.db
@@ -122,7 +147,7 @@ export class VersionService {
const registered: RepositoryVersion[] = [];
for (const { tag, title } of previousVersions) {
for (const { tag, title, commitHash } of previousVersions) {
const existing = this.getByTag(repositoryId, tag);
if (existing) {
// Already registered — skip silently.
@@ -130,10 +155,42 @@ export class VersionService {
continue;
}
const version = this.add(repositoryId, tag, title);
const version = this.add(repositoryId, tag, title, commitHash);
registered.push(version);
}
return registered;
}
/**
* Discover all version tags from a local repository and return them
* along with their resolved commit hashes.
*
* This is used for tag auto-discovery when adding a repository or
* refreshing available versions (TRUEREF-0019).
*
* @returns Array of { tag, commitHash } objects, newest first
* @throws Error when repository is not local or git operations fail
*/
discoverTags(repositoryId: string): Array<{ tag: string; commitHash: string }> {
const repo = this.db
.prepare(`SELECT id, source, source_url FROM repositories WHERE id = ?`)
.get(repositoryId) as { id: string; source: string; source_url: string } | undefined;
if (!repo) {
throw new NotFoundError(`Repository ${repositoryId} not found`);
}
if (repo.source !== 'local') {
throw new Error('Tag discovery is only supported for local repositories');
}
const { discoverVersionTags, resolveTagToCommit } = require('$lib/server/utils/git.js');
const tags = discoverVersionTags({ repoPath: repo.source_url });
return tags.map((tag: string) => {
const commitHash = resolveTagToCommit({ repoPath: repo.source_url, tag });
return { tag, commitHash };
});
}
}

163
src/lib/server/utils/git.ts Normal file
View File

@@ -0,0 +1,163 @@
/**
* Git utilities for version indexing (TRUEREF-0019).
*
* Provides:
* - Tag-to-commit resolution
* - Tag auto-discovery
* - File extraction via `git archive` to temp directories
*/
import { execSync } from 'node:child_process';
import { mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
export interface ResolveTagOptions {
repoPath: string;
tag: string;
}
export interface DiscoverTagsOptions {
repoPath: string;
}
export interface ExtractVersionOptions {
repoPath: string;
commitHash: string;
repositoryId: string;
versionTag: string;
}
/**
* Resolve a git tag/branch to its underlying commit hash.
*
* Uses `git rev-parse <ref>^{commit}` which automatically dereferences
* annotated tags to the commit they point at.
*
* @throws Error when the tag does not exist or git command fails
*/
export function resolveTagToCommit(options: ResolveTagOptions): string {
const { repoPath, tag } = options;
try {
const commitHash = execSync(`git -C "${repoPath}" rev-parse "${tag}^{commit}"`, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim();
return commitHash;
} catch (error) {
throw new Error(
`Failed to resolve tag '${tag}' in repository at ${repoPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Discover all version tags in a repository.
*
* Returns an array of tag names sorted in reverse chronological order
* (most recent first).
*
* @throws Error when git command fails
*/
export function discoverVersionTags(options: DiscoverTagsOptions): string[] {
const { repoPath } = options;
try {
// List all tags, sorted by commit date (newest first)
const output = execSync(
`git -C "${repoPath}" tag -l --sort=-creatordate`,
{
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe']
}
).trim();
if (!output) return [];
return output.split('\n').filter((tag) => tag.length > 0);
} catch (error) {
throw new Error(
`Failed to discover tags in repository at ${repoPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Extract a clean file tree from a specific commit using `git archive`.
*
* The extracted files are placed in a temporary directory under
* `prompts/{jira}/tmp/` with naming convention:
* `{repositoryId.replace(/\//g, '_')}-{versionTag}/`
*
* Example:
* repo: "/facebook/react"
* tag: "v18.3.0"
* → temp path: "{workspace}/prompts/TRUEREF-0019/tmp/_facebook_react-v18.3.0/"
*
* The temp directory MUST be deleted after indexing completes.
*
* @returns absolute path to the extracted directory
* @throws Error when git archive fails
*/
export function extractVersionToTemp(options: ExtractVersionOptions): string {
const { repoPath, commitHash, repositoryId, versionTag } = options;
// Create workspace-local temp directory under prompts/
// (agent-conventions rule: never use OS temp directories)
const workspaceRoot = process.cwd();
const sanitizedRepoId = repositoryId.replace(/\//g, '_');
const extractDirName = `${sanitizedRepoId}-${versionTag}`;
// Note: This assumes a JIRA context exists. For non-JIRA workflows,
// you may need to adjust the temp path or pass it as a parameter.
const tempRoot = join(workspaceRoot, 'prompts', 'tmp');
const extractPath = join(tempRoot, extractDirName);
// Clean up any existing extraction for this version
try {
rmSync(extractPath, { recursive: true, force: true });
} catch {
// Directory doesn't exist yet — no problem
}
// Create temp directory
mkdirSync(extractPath, { recursive: true });
try {
// Extract files from the commit using git archive
// Format: tar (pipe directly to tar -x for extraction)
execSync(`git -C "${repoPath}" archive "${commitHash}" | tar -x -C "${extractPath}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: '/bin/sh'
});
return extractPath;
} catch (error) {
// Clean up on failure
try {
rmSync(extractPath, { recursive: true, force: true });
} catch {
// Best effort cleanup
}
throw new Error(
`Failed to extract commit ${commitHash} to ${extractPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Clean up a temp extraction directory created by extractVersionToTemp.
*
* This should be called after indexing completes (success or failure).
*/
export function cleanupTempExtraction(extractPath: string): void {
try {
rmSync(extractPath, { recursive: true, force: true });
} catch (error) {
console.warn(
`[git.ts] Failed to cleanup temp extraction at ${extractPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}

View File

@@ -92,5 +92,5 @@ export interface TrueRefConfig {
excludeFolders?: string[];
excludeFiles?: string[];
rules?: string[];
previousVersions?: Array<{ tag: string; title: string }>;
previousVersions?: Array<{ tag: string; title: string; commitHash?: string }>;
}