@thxgg/steward 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/.env.example +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +175 -0
  4. package/app/app.vue +14 -0
  5. package/app/assets/css/main.css +129 -0
  6. package/app/components/CommandPalette.vue +182 -0
  7. package/app/components/ShortcutsHelp.vue +85 -0
  8. package/app/components/git/ChangesMinimap.vue +143 -0
  9. package/app/components/git/CommitList.vue +224 -0
  10. package/app/components/git/DiffPanel.vue +402 -0
  11. package/app/components/git/DiffViewer.vue +803 -0
  12. package/app/components/layout/RepoSelector.vue +358 -0
  13. package/app/components/layout/Sidebar.vue +91 -0
  14. package/app/components/prd/Meta.vue +69 -0
  15. package/app/components/prd/Viewer.vue +285 -0
  16. package/app/components/tasks/Board.vue +86 -0
  17. package/app/components/tasks/Card.vue +108 -0
  18. package/app/components/tasks/Column.vue +108 -0
  19. package/app/components/tasks/Detail.vue +291 -0
  20. package/app/components/ui/badge/Badge.vue +26 -0
  21. package/app/components/ui/badge/index.ts +26 -0
  22. package/app/components/ui/button/Button.vue +29 -0
  23. package/app/components/ui/button/index.ts +38 -0
  24. package/app/components/ui/card/Card.vue +22 -0
  25. package/app/components/ui/card/CardAction.vue +17 -0
  26. package/app/components/ui/card/CardContent.vue +17 -0
  27. package/app/components/ui/card/CardDescription.vue +17 -0
  28. package/app/components/ui/card/CardFooter.vue +17 -0
  29. package/app/components/ui/card/CardHeader.vue +17 -0
  30. package/app/components/ui/card/CardTitle.vue +17 -0
  31. package/app/components/ui/card/index.ts +7 -0
  32. package/app/components/ui/combobox/Combobox.vue +19 -0
  33. package/app/components/ui/combobox/ComboboxAnchor.vue +23 -0
  34. package/app/components/ui/combobox/ComboboxEmpty.vue +21 -0
  35. package/app/components/ui/combobox/ComboboxGroup.vue +27 -0
  36. package/app/components/ui/combobox/ComboboxInput.vue +42 -0
  37. package/app/components/ui/combobox/ComboboxItem.vue +24 -0
  38. package/app/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
  39. package/app/components/ui/combobox/ComboboxList.vue +33 -0
  40. package/app/components/ui/combobox/ComboboxSeparator.vue +21 -0
  41. package/app/components/ui/combobox/ComboboxTrigger.vue +24 -0
  42. package/app/components/ui/combobox/ComboboxViewport.vue +23 -0
  43. package/app/components/ui/combobox/index.ts +13 -0
  44. package/app/components/ui/command/Command.vue +103 -0
  45. package/app/components/ui/command/CommandDialog.vue +33 -0
  46. package/app/components/ui/command/CommandEmpty.vue +27 -0
  47. package/app/components/ui/command/CommandGroup.vue +45 -0
  48. package/app/components/ui/command/CommandInput.vue +54 -0
  49. package/app/components/ui/command/CommandItem.vue +76 -0
  50. package/app/components/ui/command/CommandList.vue +25 -0
  51. package/app/components/ui/command/CommandSeparator.vue +21 -0
  52. package/app/components/ui/command/CommandShortcut.vue +17 -0
  53. package/app/components/ui/command/index.ts +25 -0
  54. package/app/components/ui/dialog/Dialog.vue +19 -0
  55. package/app/components/ui/dialog/DialogClose.vue +15 -0
  56. package/app/components/ui/dialog/DialogContent.vue +53 -0
  57. package/app/components/ui/dialog/DialogDescription.vue +23 -0
  58. package/app/components/ui/dialog/DialogFooter.vue +15 -0
  59. package/app/components/ui/dialog/DialogHeader.vue +17 -0
  60. package/app/components/ui/dialog/DialogOverlay.vue +21 -0
  61. package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
  62. package/app/components/ui/dialog/DialogTitle.vue +23 -0
  63. package/app/components/ui/dialog/DialogTrigger.vue +15 -0
  64. package/app/components/ui/dialog/index.ts +10 -0
  65. package/app/components/ui/input/Input.vue +33 -0
  66. package/app/components/ui/input/index.ts +1 -0
  67. package/app/components/ui/scroll-area/ScrollArea.vue +33 -0
  68. package/app/components/ui/scroll-area/ScrollBar.vue +32 -0
  69. package/app/components/ui/scroll-area/index.ts +2 -0
  70. package/app/components/ui/separator/Separator.vue +29 -0
  71. package/app/components/ui/separator/index.ts +1 -0
  72. package/app/components/ui/sheet/Sheet.vue +19 -0
  73. package/app/components/ui/sheet/SheetClose.vue +15 -0
  74. package/app/components/ui/sheet/SheetContent.vue +62 -0
  75. package/app/components/ui/sheet/SheetDescription.vue +21 -0
  76. package/app/components/ui/sheet/SheetFooter.vue +16 -0
  77. package/app/components/ui/sheet/SheetHeader.vue +15 -0
  78. package/app/components/ui/sheet/SheetOverlay.vue +21 -0
  79. package/app/components/ui/sheet/SheetTitle.vue +21 -0
  80. package/app/components/ui/sheet/SheetTrigger.vue +15 -0
  81. package/app/components/ui/sheet/index.ts +8 -0
  82. package/app/components/ui/tabs/Tabs.vue +24 -0
  83. package/app/components/ui/tabs/TabsContent.vue +21 -0
  84. package/app/components/ui/tabs/TabsList.vue +24 -0
  85. package/app/components/ui/tabs/TabsTrigger.vue +26 -0
  86. package/app/components/ui/tabs/index.ts +4 -0
  87. package/app/components/ui/tooltip/Tooltip.vue +19 -0
  88. package/app/components/ui/tooltip/TooltipContent.vue +34 -0
  89. package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
  90. package/app/components/ui/tooltip/TooltipTrigger.vue +15 -0
  91. package/app/components/ui/tooltip/index.ts +4 -0
  92. package/app/composables/useFileWatch.ts +78 -0
  93. package/app/composables/useGit.ts +180 -0
  94. package/app/composables/useKeyboard.ts +180 -0
  95. package/app/composables/usePrd.ts +86 -0
  96. package/app/composables/useRepos.ts +108 -0
  97. package/app/composables/useThemeMode.ts +38 -0
  98. package/app/composables/useToast.ts +31 -0
  99. package/app/layouts/default.vue +197 -0
  100. package/app/lib/utils.ts +7 -0
  101. package/app/pages/[repo]/[prd].vue +263 -0
  102. package/app/pages/index.vue +257 -0
  103. package/app/types/git.ts +81 -0
  104. package/app/types/index.ts +29 -0
  105. package/app/types/prd.ts +49 -0
  106. package/app/types/repo.ts +37 -0
  107. package/app/types/task.ts +134 -0
  108. package/bin/prd +21 -0
  109. package/components.json +21 -0
  110. package/dist/app/types/git.js +1 -0
  111. package/dist/app/types/prd.js +1 -0
  112. package/dist/app/types/repo.js +1 -0
  113. package/dist/app/types/task.js +1 -0
  114. package/dist/host/src/api/git.js +96 -0
  115. package/dist/host/src/api/index.js +4 -0
  116. package/dist/host/src/api/prds.js +195 -0
  117. package/dist/host/src/api/repos.js +47 -0
  118. package/dist/host/src/api/state.js +63 -0
  119. package/dist/host/src/executor.js +109 -0
  120. package/dist/host/src/index.js +95 -0
  121. package/dist/host/src/mcp.js +62 -0
  122. package/dist/host/src/ui.js +64 -0
  123. package/dist/server/utils/db.js +125 -0
  124. package/dist/server/utils/git.js +396 -0
  125. package/dist/server/utils/prd-state.js +229 -0
  126. package/dist/server/utils/repos.js +256 -0
  127. package/docs/MCP.md +180 -0
  128. package/nuxt.config.ts +34 -0
  129. package/package.json +88 -0
  130. package/public/favicon.ico +0 -0
  131. package/public/robots.txt +1 -0
  132. package/server/api/browse.get.ts +52 -0
  133. package/server/api/repos/[repoId]/git/commits.get.ts +103 -0
  134. package/server/api/repos/[repoId]/git/diff.get.ts +77 -0
  135. package/server/api/repos/[repoId]/git/file-content.get.ts +66 -0
  136. package/server/api/repos/[repoId]/git/file-diff.get.ts +109 -0
  137. package/server/api/repos/[repoId]/prd/[prdSlug]/progress.get.ts +36 -0
  138. package/server/api/repos/[repoId]/prd/[prdSlug]/tasks/[taskId]/commits.get.ts +146 -0
  139. package/server/api/repos/[repoId]/prd/[prdSlug]/tasks.get.ts +36 -0
  140. package/server/api/repos/[repoId]/prd/[prdSlug].get.ts +97 -0
  141. package/server/api/repos/[repoId]/prds.get.ts +85 -0
  142. package/server/api/repos/[repoId]/refresh-git-repos.post.ts +42 -0
  143. package/server/api/repos/[repoId].delete.ts +27 -0
  144. package/server/api/repos/index.get.ts +5 -0
  145. package/server/api/repos/index.post.ts +39 -0
  146. package/server/api/watch.get.ts +63 -0
  147. package/server/plugins/migrate-legacy-state.ts +19 -0
  148. package/server/tsconfig.json +3 -0
  149. package/server/utils/db.ts +169 -0
  150. package/server/utils/git.ts +478 -0
  151. package/server/utils/prd-state.ts +335 -0
  152. package/server/utils/repos.ts +322 -0
  153. package/server/utils/watcher.ts +179 -0
  154. package/tsconfig.json +4 -0
@@ -0,0 +1,64 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ const require = createRequire(import.meta.url);
7
+ function findPackageRoot(startDir) {
8
+ let currentDir = startDir;
9
+ while (true) {
10
+ if (existsSync(join(currentDir, 'package.json'))) {
11
+ return currentDir;
12
+ }
13
+ const parentDir = dirname(currentDir);
14
+ if (parentDir === currentDir) {
15
+ throw new Error('Unable to locate package root from current runtime path');
16
+ }
17
+ currentDir = parentDir;
18
+ }
19
+ }
20
+ const packageRoot = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
21
+ function resolveNuxtEntrypoint() {
22
+ const packageJsonPath = require.resolve('nuxt/package.json', { paths: [packageRoot] });
23
+ return join(dirname(packageJsonPath), 'bin', 'nuxt.mjs');
24
+ }
25
+ export async function runUi(options) {
26
+ const script = options.preview ? 'preview' : 'dev';
27
+ const args = [resolveNuxtEntrypoint(), script];
28
+ if (options.port !== undefined || options.host) {
29
+ if (options.port !== undefined) {
30
+ args.push('--port', String(options.port));
31
+ }
32
+ if (options.host) {
33
+ args.push('--host', options.host);
34
+ }
35
+ }
36
+ const child = spawn(process.execPath, args, {
37
+ cwd: packageRoot,
38
+ stdio: 'inherit',
39
+ env: process.env
40
+ });
41
+ return await new Promise((resolveExit, reject) => {
42
+ const forwardSignal = (signal) => {
43
+ if (!child.killed) {
44
+ child.kill(signal);
45
+ }
46
+ };
47
+ process.on('SIGINT', forwardSignal);
48
+ process.on('SIGTERM', forwardSignal);
49
+ child.on('error', (error) => {
50
+ process.off('SIGINT', forwardSignal);
51
+ process.off('SIGTERM', forwardSignal);
52
+ reject(error);
53
+ });
54
+ child.on('exit', (code, signal) => {
55
+ process.off('SIGINT', forwardSignal);
56
+ process.off('SIGTERM', forwardSignal);
57
+ if (signal) {
58
+ process.kill(process.pid, signal);
59
+ return;
60
+ }
61
+ resolveExit(code ?? 0);
62
+ });
63
+ });
64
+ }
@@ -0,0 +1,125 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ const DEFAULT_DATA_HOME = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
5
+ const DEFAULT_DB_PATH = join(DEFAULT_DATA_HOME, 'prd', 'state.db');
6
+ let adapterPromise = null;
7
+ function coerceChanges(result) {
8
+ if (!result || typeof result !== 'object') {
9
+ return 0;
10
+ }
11
+ const maybeChanges = result.changes;
12
+ return typeof maybeChanges === 'number' ? maybeChanges : 0;
13
+ }
14
+ function resolveDbPath() {
15
+ const customPath = process.env.PRD_STATE_DB_PATH;
16
+ if (customPath && customPath.trim().length > 0) {
17
+ return customPath;
18
+ }
19
+ const customHome = process.env.PRD_STATE_HOME;
20
+ if (customHome && customHome.trim().length > 0) {
21
+ return join(customHome, 'state.db');
22
+ }
23
+ return DEFAULT_DB_PATH;
24
+ }
25
+ export function getDbPath() {
26
+ return resolveDbPath();
27
+ }
28
+ async function createNodeAdapter(dbPath) {
29
+ const sqliteModule = await import('node:sqlite');
30
+ const db = new sqliteModule.DatabaseSync(dbPath);
31
+ return {
32
+ exec(sql) {
33
+ db.exec(sql);
34
+ },
35
+ run(sql, params = []) {
36
+ const result = db.prepare(sql).run(...params);
37
+ return { changes: coerceChanges(result) };
38
+ },
39
+ get(sql, params = []) {
40
+ const row = db.prepare(sql).get(...params);
41
+ return row ? row : null;
42
+ },
43
+ all(sql, params = []) {
44
+ return db.prepare(sql).all(...params);
45
+ }
46
+ };
47
+ }
48
+ async function createBunAdapter(dbPath) {
49
+ const bunModuleName = 'bun:sqlite';
50
+ const sqliteModule = await import(bunModuleName);
51
+ const Database = sqliteModule.Database;
52
+ const db = new Database(dbPath, { create: true });
53
+ return {
54
+ exec(sql) {
55
+ db.exec(sql);
56
+ },
57
+ run(sql, params = []) {
58
+ const result = db.query(sql).run(...params);
59
+ return { changes: coerceChanges(result) };
60
+ },
61
+ get(sql, params = []) {
62
+ const row = db.query(sql).get(...params);
63
+ return row ?? null;
64
+ },
65
+ all(sql, params = []) {
66
+ return db.query(sql).all(...params);
67
+ }
68
+ };
69
+ }
70
+ async function initializeDatabase() {
71
+ const dbPath = resolveDbPath();
72
+ await fs.mkdir(dirname(dbPath), { recursive: true });
73
+ const isBunRuntime = typeof globalThis.Bun !== 'undefined';
74
+ const adapter = isBunRuntime
75
+ ? await createBunAdapter(dbPath)
76
+ : await createNodeAdapter(dbPath);
77
+ adapter.exec('PRAGMA journal_mode = WAL;');
78
+ adapter.exec('PRAGMA foreign_keys = ON;');
79
+ adapter.exec('PRAGMA busy_timeout = 5000;');
80
+ adapter.exec(`
81
+ CREATE TABLE IF NOT EXISTS repos (
82
+ id TEXT PRIMARY KEY,
83
+ name TEXT NOT NULL,
84
+ path TEXT NOT NULL UNIQUE,
85
+ added_at TEXT NOT NULL,
86
+ git_repos_json TEXT
87
+ );
88
+
89
+ CREATE TABLE IF NOT EXISTS prd_states (
90
+ repo_id TEXT NOT NULL,
91
+ slug TEXT NOT NULL,
92
+ tasks_json TEXT,
93
+ progress_json TEXT,
94
+ notes_md TEXT,
95
+ updated_at TEXT NOT NULL,
96
+ PRIMARY KEY (repo_id, slug),
97
+ FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE
98
+ );
99
+
100
+ CREATE INDEX IF NOT EXISTS idx_prd_states_repo_id ON prd_states(repo_id);
101
+ `);
102
+ return adapter;
103
+ }
104
+ async function getAdapter() {
105
+ if (!adapterPromise) {
106
+ adapterPromise = initializeDatabase();
107
+ }
108
+ return adapterPromise;
109
+ }
110
+ export async function dbRun(sql, params = []) {
111
+ const adapter = await getAdapter();
112
+ return adapter.run(sql, params);
113
+ }
114
+ export async function dbGet(sql, params = []) {
115
+ const adapter = await getAdapter();
116
+ return adapter.get(sql, params);
117
+ }
118
+ export async function dbAll(sql, params = []) {
119
+ const adapter = await getAdapter();
120
+ return adapter.all(sql, params);
121
+ }
122
+ export async function dbExec(sql) {
123
+ const adapter = await getAdapter();
124
+ adapter.exec(sql);
125
+ }
@@ -0,0 +1,396 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import { join, resolve, relative, isAbsolute } from 'node:path';
4
+ /**
5
+ * Execute a git command and return stdout
6
+ */
7
+ async function execGit(repoPath, args) {
8
+ return new Promise((resolve, reject) => {
9
+ const proc = spawn('git', args, {
10
+ cwd: repoPath,
11
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
12
+ });
13
+ let stdout = '';
14
+ let stderr = '';
15
+ proc.stdout.on('data', (data) => {
16
+ stdout += data.toString();
17
+ });
18
+ proc.stderr.on('data', (data) => {
19
+ stderr += data.toString();
20
+ });
21
+ proc.on('close', (code) => {
22
+ if (code === 0) {
23
+ resolve(stdout);
24
+ }
25
+ else {
26
+ reject(new Error(stderr || `git exited with code ${code}`));
27
+ }
28
+ });
29
+ proc.on('error', (err) => {
30
+ reject(new Error(`Failed to spawn git: ${err.message}`));
31
+ });
32
+ });
33
+ }
34
+ /**
35
+ * Check if a path is a valid git repository
36
+ */
37
+ export async function isGitRepo(path) {
38
+ try {
39
+ await execGit(path, ['rev-parse', '--git-dir']);
40
+ return true;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /**
47
+ * Validate that a file path is within the repository
48
+ */
49
+ export function validatePathInRepo(repoPath, filePath) {
50
+ const resolvedRepo = resolve(repoPath);
51
+ const resolvedFile = isAbsolute(filePath)
52
+ ? resolve(filePath)
53
+ : resolve(repoPath, filePath);
54
+ // Check that the file is within the repo
55
+ const relativePath = relative(resolvedRepo, resolvedFile);
56
+ return !relativePath.startsWith('..') && !isAbsolute(relativePath);
57
+ }
58
+ /**
59
+ * Get commit information by SHA
60
+ */
61
+ export async function getCommitInfo(repoPath, sha) {
62
+ // Validate SHA format (hex string, 4-40 chars)
63
+ if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
64
+ throw new Error(`Invalid commit SHA: ${sha}`);
65
+ }
66
+ // Get commit details
67
+ const format = '%H%n%h%n%s%n%an%n%aI';
68
+ const output = await execGit(repoPath, ['show', sha, '--format=' + format, '--no-patch']);
69
+ const lines = output.trim().split('\n');
70
+ if (lines.length < 5) {
71
+ throw new Error(`Failed to parse commit info for ${sha}`);
72
+ }
73
+ // Get stats
74
+ const statsOutput = await execGit(repoPath, ['show', sha, '--format=', '--numstat']);
75
+ const statsLines = statsOutput.trim().split('\n').filter(l => l.trim());
76
+ let additions = 0;
77
+ let deletions = 0;
78
+ let filesChanged = 0;
79
+ for (const line of statsLines) {
80
+ const parts = line.split('\t');
81
+ const added = parts[0];
82
+ const deleted = parts[1];
83
+ if (added && deleted && added !== '-' && deleted !== '-') {
84
+ additions += parseInt(added, 10) || 0;
85
+ deletions += parseInt(deleted, 10) || 0;
86
+ }
87
+ filesChanged++;
88
+ }
89
+ return {
90
+ sha: lines[0],
91
+ shortSha: lines[1],
92
+ message: lines[2],
93
+ author: lines[3],
94
+ date: lines[4],
95
+ filesChanged,
96
+ additions,
97
+ deletions,
98
+ };
99
+ }
100
+ /**
101
+ * Get list of changed files in a commit with stats
102
+ */
103
+ export async function getCommitDiff(repoPath, sha) {
104
+ // Validate SHA format
105
+ if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
106
+ throw new Error(`Invalid commit SHA: ${sha}`);
107
+ }
108
+ // Get file status and stats
109
+ const output = await execGit(repoPath, [
110
+ 'show', sha,
111
+ '--format=',
112
+ '--name-status',
113
+ '--numstat',
114
+ ]);
115
+ // Parse the output - first part is numstat, then name-status
116
+ const lines = output.trim().split('\n').filter(l => l.trim());
117
+ // We need to get both numstat and name-status info
118
+ const numstatOutput = await execGit(repoPath, ['show', sha, '--format=', '--numstat']);
119
+ const nameStatusOutput = await execGit(repoPath, ['show', sha, '--format=', '--name-status']);
120
+ const numstatLines = numstatOutput.trim().split('\n').filter(l => l.trim());
121
+ const nameStatusLines = nameStatusOutput.trim().split('\n').filter(l => l.trim());
122
+ const files = [];
123
+ const statsMap = new Map();
124
+ // Parse numstat (additions, deletions, path)
125
+ // Binary files show as "-\t-\tfilepath"
126
+ for (const line of numstatLines) {
127
+ const parts = line.split('\t');
128
+ if (parts.length >= 3) {
129
+ const added = parts[0];
130
+ const deleted = parts[1];
131
+ const pathParts = parts.slice(2);
132
+ const path = pathParts.join('\t'); // Handle paths with tabs (rare but possible)
133
+ const isBinary = added === '-' && deleted === '-';
134
+ statsMap.set(path, {
135
+ additions: isBinary ? 0 : parseInt(added, 10) || 0,
136
+ deletions: isBinary ? 0 : parseInt(deleted, 10) || 0,
137
+ binary: isBinary,
138
+ });
139
+ }
140
+ }
141
+ // Parse name-status (status, path, [oldPath for renames])
142
+ for (const line of nameStatusLines) {
143
+ const parts = line.split('\t');
144
+ if (parts.length < 2 || !parts[0] || !parts[1])
145
+ continue;
146
+ const statusChar = parts[0].charAt(0);
147
+ let status;
148
+ let path;
149
+ let oldPath;
150
+ switch (statusChar) {
151
+ case 'A':
152
+ status = 'added';
153
+ path = parts[1];
154
+ break;
155
+ case 'D':
156
+ status = 'deleted';
157
+ path = parts[1];
158
+ break;
159
+ case 'M':
160
+ status = 'modified';
161
+ path = parts[1];
162
+ break;
163
+ case 'R':
164
+ status = 'renamed';
165
+ oldPath = parts[1];
166
+ path = parts[2] || parts[1];
167
+ break;
168
+ case 'C':
169
+ status = 'added'; // Treat copy as added
170
+ path = parts[2] || parts[1];
171
+ break;
172
+ default:
173
+ status = 'modified';
174
+ path = parts[1];
175
+ }
176
+ // Get stats for this file
177
+ const stats = statsMap.get(path) ||
178
+ (oldPath ? statsMap.get(`${oldPath} => ${path}`) : undefined) ||
179
+ statsMap.get(`${oldPath}\t${path}`) ||
180
+ { additions: 0, deletions: 0, binary: false };
181
+ files.push({
182
+ path,
183
+ status,
184
+ oldPath,
185
+ additions: stats.additions,
186
+ deletions: stats.deletions,
187
+ binary: stats.binary,
188
+ });
189
+ }
190
+ return files;
191
+ }
192
+ /**
193
+ * Get diff hunks for a specific file in a commit
194
+ */
195
+ export async function getFileDiff(repoPath, sha, filePath) {
196
+ // Validate SHA format
197
+ if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
198
+ throw new Error(`Invalid commit SHA: ${sha}`);
199
+ }
200
+ // Validate path is within repo
201
+ if (!validatePathInRepo(repoPath, filePath)) {
202
+ throw new Error('File path is outside repository');
203
+ }
204
+ // Get diff for specific file
205
+ const output = await execGit(repoPath, [
206
+ 'show', sha,
207
+ '--format=',
208
+ '--unified=3',
209
+ '--', filePath,
210
+ ]);
211
+ return parseDiffHunks(output);
212
+ }
213
+ /**
214
+ * Parse git diff output into hunks
215
+ */
216
+ function parseDiffHunks(diffOutput) {
217
+ const hunks = [];
218
+ const lines = diffOutput.split('\n');
219
+ let currentHunk = null;
220
+ let oldLineNum = 0;
221
+ let newLineNum = 0;
222
+ for (const line of lines) {
223
+ // Hunk header: @@ -oldStart,oldLines +newStart,newLines @@
224
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
225
+ if (hunkMatch) {
226
+ if (currentHunk) {
227
+ hunks.push(currentHunk);
228
+ }
229
+ const oldStart = parseInt(hunkMatch[1], 10);
230
+ const oldLines = hunkMatch[2] ? parseInt(hunkMatch[2], 10) : 1;
231
+ const newStart = parseInt(hunkMatch[3], 10);
232
+ const newLines = hunkMatch[4] ? parseInt(hunkMatch[4], 10) : 1;
233
+ currentHunk = {
234
+ oldStart,
235
+ oldLines,
236
+ newStart,
237
+ newLines,
238
+ lines: [],
239
+ };
240
+ oldLineNum = oldStart;
241
+ newLineNum = newStart;
242
+ continue;
243
+ }
244
+ // Skip diff headers
245
+ if (line.startsWith('diff --git') ||
246
+ line.startsWith('index ') ||
247
+ line.startsWith('---') ||
248
+ line.startsWith('+++') ||
249
+ line.startsWith('\\')) {
250
+ continue;
251
+ }
252
+ // Parse diff lines
253
+ if (currentHunk) {
254
+ if (line.startsWith('+')) {
255
+ const diffLine = {
256
+ type: 'add',
257
+ content: line.substring(1),
258
+ newNumber: newLineNum++,
259
+ };
260
+ currentHunk.lines.push(diffLine);
261
+ }
262
+ else if (line.startsWith('-')) {
263
+ const diffLine = {
264
+ type: 'remove',
265
+ content: line.substring(1),
266
+ oldNumber: oldLineNum++,
267
+ };
268
+ currentHunk.lines.push(diffLine);
269
+ }
270
+ else if (line.startsWith(' ') || line === '') {
271
+ const diffLine = {
272
+ type: 'context',
273
+ content: line.substring(1),
274
+ oldNumber: oldLineNum++,
275
+ newNumber: newLineNum++,
276
+ };
277
+ currentHunk.lines.push(diffLine);
278
+ }
279
+ }
280
+ }
281
+ if (currentHunk) {
282
+ hunks.push(currentHunk);
283
+ }
284
+ return hunks;
285
+ }
286
+ /**
287
+ * Check if a file is binary by attempting to get its diff
288
+ */
289
+ export async function isBinaryFile(repoPath, sha, filePath) {
290
+ try {
291
+ const output = await execGit(repoPath, [
292
+ 'show', sha,
293
+ '--format=',
294
+ '--', filePath,
295
+ ]);
296
+ return output.includes('Binary files');
297
+ }
298
+ catch {
299
+ return false;
300
+ }
301
+ }
302
+ /**
303
+ * Get file content at a specific commit
304
+ */
305
+ export async function getFileContent(repoPath, sha, filePath) {
306
+ // Validate SHA format
307
+ if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
308
+ throw new Error(`Invalid commit SHA: ${sha}`);
309
+ }
310
+ // Validate path is within repo
311
+ if (!validatePathInRepo(repoPath, filePath)) {
312
+ throw new Error('File path is outside repository');
313
+ }
314
+ // Get file content at commit
315
+ const output = await execGit(repoPath, ['show', `${sha}:${filePath}`]);
316
+ return output;
317
+ }
318
+ /**
319
+ * Check if a commit exists in a repository
320
+ */
321
+ async function commitExistsInRepo(repoPath, sha) {
322
+ try {
323
+ await execGit(repoPath, ['cat-file', '-t', sha]);
324
+ return true;
325
+ }
326
+ catch {
327
+ return false;
328
+ }
329
+ }
330
+ /**
331
+ * Find which repository contains a given commit SHA.
332
+ * Checks the root path first (if it's a git repo), then searches discovered repos in parallel.
333
+ *
334
+ * @param repoConfig - The repository configuration with optional gitRepos
335
+ * @param sha - The commit SHA to find
336
+ * @returns The GitRepoInfo where the commit was found, or throws if not found
337
+ */
338
+ export async function findRepoForCommit(repoConfig, sha) {
339
+ // Validate SHA format
340
+ if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
341
+ throw new Error(`Invalid commit SHA: ${sha}`);
342
+ }
343
+ // Check if root path is a git repo first
344
+ if (await isGitRepo(repoConfig.path)) {
345
+ if (await commitExistsInRepo(repoConfig.path, sha)) {
346
+ return {
347
+ sha,
348
+ repoPath: '',
349
+ absolutePath: repoConfig.path,
350
+ };
351
+ }
352
+ }
353
+ // If no discovered repos, commit not found
354
+ if (!repoConfig.gitRepos || repoConfig.gitRepos.length === 0) {
355
+ throw new Error(`Commit ${sha.substring(0, 7)} not found in repository "${repoConfig.name}"`);
356
+ }
357
+ // Search discovered repos in parallel
358
+ const results = await Promise.all(repoConfig.gitRepos.map(async (gitRepo) => {
359
+ if (await commitExistsInRepo(gitRepo.absolutePath, sha)) {
360
+ return {
361
+ sha,
362
+ repoPath: gitRepo.relativePath,
363
+ absolutePath: gitRepo.absolutePath,
364
+ };
365
+ }
366
+ return null;
367
+ }));
368
+ // Find first match
369
+ const found = results.find((r) => r !== null);
370
+ if (found) {
371
+ return found;
372
+ }
373
+ throw new Error(`Commit ${sha.substring(0, 7)} not found in repository "${repoConfig.name}" or any of its ${repoConfig.gitRepos.length} discovered git repos`);
374
+ }
375
+ /**
376
+ * Resolve a commit entry (string or CommitRef) to its repository information.
377
+ * For CommitRef objects, returns immediately (O(1)).
378
+ * For string SHAs, searches repositories to find the commit.
379
+ *
380
+ * @param repoConfig - The repository configuration
381
+ * @param commitEntry - Either a commit SHA string or a CommitRef object
382
+ * @returns Resolved commit information with repo path
383
+ */
384
+ export async function resolveCommitRepo(repoConfig, commitEntry) {
385
+ // If it's a CommitRef object, we already have the repo info (O(1))
386
+ if (typeof commitEntry === 'object' && commitEntry.sha && commitEntry.repo) {
387
+ return {
388
+ sha: commitEntry.sha,
389
+ repoPath: commitEntry.repo,
390
+ absolutePath: join(repoConfig.path, commitEntry.repo),
391
+ };
392
+ }
393
+ // It's a string SHA, need to search for it
394
+ const sha = typeof commitEntry === 'string' ? commitEntry : commitEntry.sha;
395
+ return findRepoForCommit(repoConfig, sha);
396
+ }