@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,478 @@
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
+ import type { GitCommit, FileDiff, DiffHunk, DiffLine, FileStatus, DiffLineType } from '../../app/types/git.js'
5
+ import type { RepoConfig, GitRepoInfo } from '../../app/types/repo.js'
6
+ import type { CommitRef } from '../../app/types/task.js'
7
+
8
+ /**
9
+ * Execute a git command and return stdout
10
+ */
11
+ async function execGit(repoPath: string, args: string[]): Promise<string> {
12
+ return new Promise((resolve, reject) => {
13
+ const proc = spawn('git', args, {
14
+ cwd: repoPath,
15
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
16
+ })
17
+
18
+ let stdout = ''
19
+ let stderr = ''
20
+
21
+ proc.stdout.on('data', (data) => {
22
+ stdout += data.toString()
23
+ })
24
+
25
+ proc.stderr.on('data', (data) => {
26
+ stderr += data.toString()
27
+ })
28
+
29
+ proc.on('close', (code) => {
30
+ if (code === 0) {
31
+ resolve(stdout)
32
+ } else {
33
+ reject(new Error(stderr || `git exited with code ${code}`))
34
+ }
35
+ })
36
+
37
+ proc.on('error', (err) => {
38
+ reject(new Error(`Failed to spawn git: ${err.message}`))
39
+ })
40
+ })
41
+ }
42
+
43
+ /**
44
+ * Check if a path is a valid git repository
45
+ */
46
+ export async function isGitRepo(path: string): Promise<boolean> {
47
+ try {
48
+ await execGit(path, ['rev-parse', '--git-dir'])
49
+ return true
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Validate that a file path is within the repository
57
+ */
58
+ export function validatePathInRepo(repoPath: string, filePath: string): boolean {
59
+ const resolvedRepo = resolve(repoPath)
60
+ const resolvedFile = isAbsolute(filePath)
61
+ ? resolve(filePath)
62
+ : resolve(repoPath, filePath)
63
+
64
+ // Check that the file is within the repo
65
+ const relativePath = relative(resolvedRepo, resolvedFile)
66
+ return !relativePath.startsWith('..') && !isAbsolute(relativePath)
67
+ }
68
+
69
+ /**
70
+ * Get commit information by SHA
71
+ */
72
+ export async function getCommitInfo(repoPath: string, sha: string): Promise<GitCommit> {
73
+ // Validate SHA format (hex string, 4-40 chars)
74
+ if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
75
+ throw new Error(`Invalid commit SHA: ${sha}`)
76
+ }
77
+
78
+ // Get commit details
79
+ const format = '%H%n%h%n%s%n%an%n%aI'
80
+ const output = await execGit(repoPath, ['show', sha, '--format=' + format, '--no-patch'])
81
+ const lines = output.trim().split('\n')
82
+
83
+ if (lines.length < 5) {
84
+ throw new Error(`Failed to parse commit info for ${sha}`)
85
+ }
86
+
87
+ // Get stats
88
+ const statsOutput = await execGit(repoPath, ['show', sha, '--format=', '--numstat'])
89
+ const statsLines = statsOutput.trim().split('\n').filter(l => l.trim())
90
+
91
+ let additions = 0
92
+ let deletions = 0
93
+ let filesChanged = 0
94
+
95
+ for (const line of statsLines) {
96
+ const parts = line.split('\t')
97
+ const added = parts[0]
98
+ const deleted = parts[1]
99
+ if (added && deleted && added !== '-' && deleted !== '-') {
100
+ additions += parseInt(added, 10) || 0
101
+ deletions += parseInt(deleted, 10) || 0
102
+ }
103
+ filesChanged++
104
+ }
105
+
106
+ return {
107
+ sha: lines[0]!,
108
+ shortSha: lines[1]!,
109
+ message: lines[2]!,
110
+ author: lines[3]!,
111
+ date: lines[4]!,
112
+ filesChanged,
113
+ additions,
114
+ deletions,
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Get list of changed files in a commit with stats
120
+ */
121
+ export async function getCommitDiff(repoPath: string, sha: string): Promise<FileDiff[]> {
122
+ // Validate SHA format
123
+ if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
124
+ throw new Error(`Invalid commit SHA: ${sha}`)
125
+ }
126
+
127
+ // Get file status and stats
128
+ const output = await execGit(repoPath, [
129
+ 'show', sha,
130
+ '--format=',
131
+ '--name-status',
132
+ '--numstat',
133
+ ])
134
+
135
+ // Parse the output - first part is numstat, then name-status
136
+ const lines = output.trim().split('\n').filter(l => l.trim())
137
+
138
+ // We need to get both numstat and name-status info
139
+ const numstatOutput = await execGit(repoPath, ['show', sha, '--format=', '--numstat'])
140
+ const nameStatusOutput = await execGit(repoPath, ['show', sha, '--format=', '--name-status'])
141
+
142
+ const numstatLines = numstatOutput.trim().split('\n').filter(l => l.trim())
143
+ const nameStatusLines = nameStatusOutput.trim().split('\n').filter(l => l.trim())
144
+
145
+ const files: FileDiff[] = []
146
+ const statsMap = new Map<string, { additions: number; deletions: number; binary: boolean }>()
147
+
148
+ // Parse numstat (additions, deletions, path)
149
+ // Binary files show as "-\t-\tfilepath"
150
+ for (const line of numstatLines) {
151
+ const parts = line.split('\t')
152
+ if (parts.length >= 3) {
153
+ const added = parts[0]!
154
+ const deleted = parts[1]!
155
+ const pathParts = parts.slice(2)
156
+ const path = pathParts.join('\t') // Handle paths with tabs (rare but possible)
157
+ const isBinary = added === '-' && deleted === '-'
158
+ statsMap.set(path, {
159
+ additions: isBinary ? 0 : parseInt(added, 10) || 0,
160
+ deletions: isBinary ? 0 : parseInt(deleted, 10) || 0,
161
+ binary: isBinary,
162
+ })
163
+ }
164
+ }
165
+
166
+ // Parse name-status (status, path, [oldPath for renames])
167
+ for (const line of nameStatusLines) {
168
+ const parts = line.split('\t')
169
+ if (parts.length < 2 || !parts[0] || !parts[1]) continue
170
+
171
+ const statusChar = parts[0].charAt(0)
172
+ let status: FileStatus
173
+ let path: string
174
+ let oldPath: string | undefined
175
+
176
+ switch (statusChar) {
177
+ case 'A':
178
+ status = 'added'
179
+ path = parts[1]
180
+ break
181
+ case 'D':
182
+ status = 'deleted'
183
+ path = parts[1]
184
+ break
185
+ case 'M':
186
+ status = 'modified'
187
+ path = parts[1]
188
+ break
189
+ case 'R':
190
+ status = 'renamed'
191
+ oldPath = parts[1]
192
+ path = parts[2] || parts[1]
193
+ break
194
+ case 'C':
195
+ status = 'added' // Treat copy as added
196
+ path = parts[2] || parts[1]
197
+ break
198
+ default:
199
+ status = 'modified'
200
+ path = parts[1]
201
+ }
202
+
203
+ // Get stats for this file
204
+ const stats = statsMap.get(path) ||
205
+ (oldPath ? statsMap.get(`${oldPath} => ${path}`) : undefined) ||
206
+ statsMap.get(`${oldPath}\t${path}`) ||
207
+ { additions: 0, deletions: 0, binary: false }
208
+
209
+ files.push({
210
+ path,
211
+ status,
212
+ oldPath,
213
+ additions: stats.additions,
214
+ deletions: stats.deletions,
215
+ binary: stats.binary,
216
+ })
217
+ }
218
+
219
+ return files
220
+ }
221
+
222
+ /**
223
+ * Get diff hunks for a specific file in a commit
224
+ */
225
+ export async function getFileDiff(
226
+ repoPath: string,
227
+ sha: string,
228
+ filePath: string
229
+ ): Promise<DiffHunk[]> {
230
+ // Validate SHA format
231
+ if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
232
+ throw new Error(`Invalid commit SHA: ${sha}`)
233
+ }
234
+
235
+ // Validate path is within repo
236
+ if (!validatePathInRepo(repoPath, filePath)) {
237
+ throw new Error('File path is outside repository')
238
+ }
239
+
240
+ // Get diff for specific file
241
+ const output = await execGit(repoPath, [
242
+ 'show', sha,
243
+ '--format=',
244
+ '--unified=3',
245
+ '--', filePath,
246
+ ])
247
+
248
+ return parseDiffHunks(output)
249
+ }
250
+
251
+ /**
252
+ * Parse git diff output into hunks
253
+ */
254
+ function parseDiffHunks(diffOutput: string): DiffHunk[] {
255
+ const hunks: DiffHunk[] = []
256
+ const lines = diffOutput.split('\n')
257
+
258
+ let currentHunk: DiffHunk | null = null
259
+ let oldLineNum = 0
260
+ let newLineNum = 0
261
+
262
+ for (const line of lines) {
263
+ // Hunk header: @@ -oldStart,oldLines +newStart,newLines @@
264
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
265
+ if (hunkMatch) {
266
+ if (currentHunk) {
267
+ hunks.push(currentHunk)
268
+ }
269
+
270
+ const oldStart = parseInt(hunkMatch[1]!, 10)
271
+ const oldLines = hunkMatch[2] ? parseInt(hunkMatch[2], 10) : 1
272
+ const newStart = parseInt(hunkMatch[3]!, 10)
273
+ const newLines = hunkMatch[4] ? parseInt(hunkMatch[4], 10) : 1
274
+
275
+ currentHunk = {
276
+ oldStart,
277
+ oldLines,
278
+ newStart,
279
+ newLines,
280
+ lines: [],
281
+ }
282
+
283
+ oldLineNum = oldStart
284
+ newLineNum = newStart
285
+ continue
286
+ }
287
+
288
+ // Skip diff headers
289
+ if (line.startsWith('diff --git') ||
290
+ line.startsWith('index ') ||
291
+ line.startsWith('---') ||
292
+ line.startsWith('+++') ||
293
+ line.startsWith('\\')) {
294
+ continue
295
+ }
296
+
297
+ // Parse diff lines
298
+ if (currentHunk) {
299
+ if (line.startsWith('+')) {
300
+ const diffLine: DiffLine = {
301
+ type: 'add',
302
+ content: line.substring(1),
303
+ newNumber: newLineNum++,
304
+ }
305
+ currentHunk.lines.push(diffLine)
306
+ } else if (line.startsWith('-')) {
307
+ const diffLine: DiffLine = {
308
+ type: 'remove',
309
+ content: line.substring(1),
310
+ oldNumber: oldLineNum++,
311
+ }
312
+ currentHunk.lines.push(diffLine)
313
+ } else if (line.startsWith(' ') || line === '') {
314
+ const diffLine: DiffLine = {
315
+ type: 'context',
316
+ content: line.substring(1),
317
+ oldNumber: oldLineNum++,
318
+ newNumber: newLineNum++,
319
+ }
320
+ currentHunk.lines.push(diffLine)
321
+ }
322
+ }
323
+ }
324
+
325
+ if (currentHunk) {
326
+ hunks.push(currentHunk)
327
+ }
328
+
329
+ return hunks
330
+ }
331
+
332
+ /**
333
+ * Check if a file is binary by attempting to get its diff
334
+ */
335
+ export async function isBinaryFile(repoPath: string, sha: string, filePath: string): Promise<boolean> {
336
+ try {
337
+ const output = await execGit(repoPath, [
338
+ 'show', sha,
339
+ '--format=',
340
+ '--', filePath,
341
+ ])
342
+ return output.includes('Binary files')
343
+ } catch {
344
+ return false
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Get file content at a specific commit
350
+ */
351
+ export async function getFileContent(
352
+ repoPath: string,
353
+ sha: string,
354
+ filePath: string
355
+ ): Promise<string> {
356
+ // Validate SHA format
357
+ if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
358
+ throw new Error(`Invalid commit SHA: ${sha}`)
359
+ }
360
+
361
+ // Validate path is within repo
362
+ if (!validatePathInRepo(repoPath, filePath)) {
363
+ throw new Error('File path is outside repository')
364
+ }
365
+
366
+ // Get file content at commit
367
+ const output = await execGit(repoPath, ['show', `${sha}:${filePath}`])
368
+ return output
369
+ }
370
+
371
+ /**
372
+ * Check if a commit exists in a repository
373
+ */
374
+ async function commitExistsInRepo(repoPath: string, sha: string): Promise<boolean> {
375
+ try {
376
+ await execGit(repoPath, ['cat-file', '-t', sha])
377
+ return true
378
+ } catch {
379
+ return false
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Result of resolving a commit to its repository
385
+ */
386
+ export interface ResolvedCommit {
387
+ /** Commit SHA */
388
+ sha: string
389
+ /** Relative path to the git repo (empty string for root repo) */
390
+ repoPath: string
391
+ /** Absolute path to the git repo */
392
+ absolutePath: string
393
+ }
394
+
395
+ /**
396
+ * Find which repository contains a given commit SHA.
397
+ * Checks the root path first (if it's a git repo), then searches discovered repos in parallel.
398
+ *
399
+ * @param repoConfig - The repository configuration with optional gitRepos
400
+ * @param sha - The commit SHA to find
401
+ * @returns The GitRepoInfo where the commit was found, or throws if not found
402
+ */
403
+ export async function findRepoForCommit(
404
+ repoConfig: RepoConfig,
405
+ sha: string
406
+ ): Promise<ResolvedCommit> {
407
+ // Validate SHA format
408
+ if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
409
+ throw new Error(`Invalid commit SHA: ${sha}`)
410
+ }
411
+
412
+ // Check if root path is a git repo first
413
+ if (await isGitRepo(repoConfig.path)) {
414
+ if (await commitExistsInRepo(repoConfig.path, sha)) {
415
+ return {
416
+ sha,
417
+ repoPath: '',
418
+ absolutePath: repoConfig.path,
419
+ }
420
+ }
421
+ }
422
+
423
+ // If no discovered repos, commit not found
424
+ if (!repoConfig.gitRepos || repoConfig.gitRepos.length === 0) {
425
+ throw new Error(`Commit ${sha.substring(0, 7)} not found in repository "${repoConfig.name}"`)
426
+ }
427
+
428
+ // Search discovered repos in parallel
429
+ const results = await Promise.all(
430
+ repoConfig.gitRepos.map(async (gitRepo): Promise<ResolvedCommit | null> => {
431
+ if (await commitExistsInRepo(gitRepo.absolutePath, sha)) {
432
+ return {
433
+ sha,
434
+ repoPath: gitRepo.relativePath,
435
+ absolutePath: gitRepo.absolutePath,
436
+ }
437
+ }
438
+ return null
439
+ })
440
+ )
441
+
442
+ // Find first match
443
+ const found = results.find((r): r is ResolvedCommit => r !== null)
444
+ if (found) {
445
+ return found
446
+ }
447
+
448
+ throw new Error(
449
+ `Commit ${sha.substring(0, 7)} not found in repository "${repoConfig.name}" or any of its ${repoConfig.gitRepos.length} discovered git repos`
450
+ )
451
+ }
452
+
453
+ /**
454
+ * Resolve a commit entry (string or CommitRef) to its repository information.
455
+ * For CommitRef objects, returns immediately (O(1)).
456
+ * For string SHAs, searches repositories to find the commit.
457
+ *
458
+ * @param repoConfig - The repository configuration
459
+ * @param commitEntry - Either a commit SHA string or a CommitRef object
460
+ * @returns Resolved commit information with repo path
461
+ */
462
+ export async function resolveCommitRepo(
463
+ repoConfig: RepoConfig,
464
+ commitEntry: string | CommitRef
465
+ ): Promise<ResolvedCommit> {
466
+ // If it's a CommitRef object, we already have the repo info (O(1))
467
+ if (typeof commitEntry === 'object' && commitEntry.sha && commitEntry.repo) {
468
+ return {
469
+ sha: commitEntry.sha,
470
+ repoPath: commitEntry.repo,
471
+ absolutePath: join(repoConfig.path, commitEntry.repo),
472
+ }
473
+ }
474
+
475
+ // It's a string SHA, need to search for it
476
+ const sha = typeof commitEntry === 'string' ? commitEntry : commitEntry.sha
477
+ return findRepoForCommit(repoConfig, sha)
478
+ }