@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.
- package/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/app/app.vue +14 -0
- package/app/assets/css/main.css +129 -0
- package/app/components/CommandPalette.vue +182 -0
- package/app/components/ShortcutsHelp.vue +85 -0
- package/app/components/git/ChangesMinimap.vue +143 -0
- package/app/components/git/CommitList.vue +224 -0
- package/app/components/git/DiffPanel.vue +402 -0
- package/app/components/git/DiffViewer.vue +803 -0
- package/app/components/layout/RepoSelector.vue +358 -0
- package/app/components/layout/Sidebar.vue +91 -0
- package/app/components/prd/Meta.vue +69 -0
- package/app/components/prd/Viewer.vue +285 -0
- package/app/components/tasks/Board.vue +86 -0
- package/app/components/tasks/Card.vue +108 -0
- package/app/components/tasks/Column.vue +108 -0
- package/app/components/tasks/Detail.vue +291 -0
- package/app/components/ui/badge/Badge.vue +26 -0
- package/app/components/ui/badge/index.ts +26 -0
- package/app/components/ui/button/Button.vue +29 -0
- package/app/components/ui/button/index.ts +38 -0
- package/app/components/ui/card/Card.vue +22 -0
- package/app/components/ui/card/CardAction.vue +17 -0
- package/app/components/ui/card/CardContent.vue +17 -0
- package/app/components/ui/card/CardDescription.vue +17 -0
- package/app/components/ui/card/CardFooter.vue +17 -0
- package/app/components/ui/card/CardHeader.vue +17 -0
- package/app/components/ui/card/CardTitle.vue +17 -0
- package/app/components/ui/card/index.ts +7 -0
- package/app/components/ui/combobox/Combobox.vue +19 -0
- package/app/components/ui/combobox/ComboboxAnchor.vue +23 -0
- package/app/components/ui/combobox/ComboboxEmpty.vue +21 -0
- package/app/components/ui/combobox/ComboboxGroup.vue +27 -0
- package/app/components/ui/combobox/ComboboxInput.vue +42 -0
- package/app/components/ui/combobox/ComboboxItem.vue +24 -0
- package/app/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
- package/app/components/ui/combobox/ComboboxList.vue +33 -0
- package/app/components/ui/combobox/ComboboxSeparator.vue +21 -0
- package/app/components/ui/combobox/ComboboxTrigger.vue +24 -0
- package/app/components/ui/combobox/ComboboxViewport.vue +23 -0
- package/app/components/ui/combobox/index.ts +13 -0
- package/app/components/ui/command/Command.vue +103 -0
- package/app/components/ui/command/CommandDialog.vue +33 -0
- package/app/components/ui/command/CommandEmpty.vue +27 -0
- package/app/components/ui/command/CommandGroup.vue +45 -0
- package/app/components/ui/command/CommandInput.vue +54 -0
- package/app/components/ui/command/CommandItem.vue +76 -0
- package/app/components/ui/command/CommandList.vue +25 -0
- package/app/components/ui/command/CommandSeparator.vue +21 -0
- package/app/components/ui/command/CommandShortcut.vue +17 -0
- package/app/components/ui/command/index.ts +25 -0
- package/app/components/ui/dialog/Dialog.vue +19 -0
- package/app/components/ui/dialog/DialogClose.vue +15 -0
- package/app/components/ui/dialog/DialogContent.vue +53 -0
- package/app/components/ui/dialog/DialogDescription.vue +23 -0
- package/app/components/ui/dialog/DialogFooter.vue +15 -0
- package/app/components/ui/dialog/DialogHeader.vue +17 -0
- package/app/components/ui/dialog/DialogOverlay.vue +21 -0
- package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
- package/app/components/ui/dialog/DialogTitle.vue +23 -0
- package/app/components/ui/dialog/DialogTrigger.vue +15 -0
- package/app/components/ui/dialog/index.ts +10 -0
- package/app/components/ui/input/Input.vue +33 -0
- package/app/components/ui/input/index.ts +1 -0
- package/app/components/ui/scroll-area/ScrollArea.vue +33 -0
- package/app/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/app/components/ui/scroll-area/index.ts +2 -0
- package/app/components/ui/separator/Separator.vue +29 -0
- package/app/components/ui/separator/index.ts +1 -0
- package/app/components/ui/sheet/Sheet.vue +19 -0
- package/app/components/ui/sheet/SheetClose.vue +15 -0
- package/app/components/ui/sheet/SheetContent.vue +62 -0
- package/app/components/ui/sheet/SheetDescription.vue +21 -0
- package/app/components/ui/sheet/SheetFooter.vue +16 -0
- package/app/components/ui/sheet/SheetHeader.vue +15 -0
- package/app/components/ui/sheet/SheetOverlay.vue +21 -0
- package/app/components/ui/sheet/SheetTitle.vue +21 -0
- package/app/components/ui/sheet/SheetTrigger.vue +15 -0
- package/app/components/ui/sheet/index.ts +8 -0
- package/app/components/ui/tabs/Tabs.vue +24 -0
- package/app/components/ui/tabs/TabsContent.vue +21 -0
- package/app/components/ui/tabs/TabsList.vue +24 -0
- package/app/components/ui/tabs/TabsTrigger.vue +26 -0
- package/app/components/ui/tabs/index.ts +4 -0
- package/app/components/ui/tooltip/Tooltip.vue +19 -0
- package/app/components/ui/tooltip/TooltipContent.vue +34 -0
- package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
- package/app/components/ui/tooltip/TooltipTrigger.vue +15 -0
- package/app/components/ui/tooltip/index.ts +4 -0
- package/app/composables/useFileWatch.ts +78 -0
- package/app/composables/useGit.ts +180 -0
- package/app/composables/useKeyboard.ts +180 -0
- package/app/composables/usePrd.ts +86 -0
- package/app/composables/useRepos.ts +108 -0
- package/app/composables/useThemeMode.ts +38 -0
- package/app/composables/useToast.ts +31 -0
- package/app/layouts/default.vue +197 -0
- package/app/lib/utils.ts +7 -0
- package/app/pages/[repo]/[prd].vue +263 -0
- package/app/pages/index.vue +257 -0
- package/app/types/git.ts +81 -0
- package/app/types/index.ts +29 -0
- package/app/types/prd.ts +49 -0
- package/app/types/repo.ts +37 -0
- package/app/types/task.ts +134 -0
- package/bin/prd +21 -0
- package/components.json +21 -0
- package/dist/app/types/git.js +1 -0
- package/dist/app/types/prd.js +1 -0
- package/dist/app/types/repo.js +1 -0
- package/dist/app/types/task.js +1 -0
- package/dist/host/src/api/git.js +96 -0
- package/dist/host/src/api/index.js +4 -0
- package/dist/host/src/api/prds.js +195 -0
- package/dist/host/src/api/repos.js +47 -0
- package/dist/host/src/api/state.js +63 -0
- package/dist/host/src/executor.js +109 -0
- package/dist/host/src/index.js +95 -0
- package/dist/host/src/mcp.js +62 -0
- package/dist/host/src/ui.js +64 -0
- package/dist/server/utils/db.js +125 -0
- package/dist/server/utils/git.js +396 -0
- package/dist/server/utils/prd-state.js +229 -0
- package/dist/server/utils/repos.js +256 -0
- package/docs/MCP.md +180 -0
- package/nuxt.config.ts +34 -0
- package/package.json +88 -0
- package/public/favicon.ico +0 -0
- package/public/robots.txt +1 -0
- package/server/api/browse.get.ts +52 -0
- package/server/api/repos/[repoId]/git/commits.get.ts +103 -0
- package/server/api/repos/[repoId]/git/diff.get.ts +77 -0
- package/server/api/repos/[repoId]/git/file-content.get.ts +66 -0
- package/server/api/repos/[repoId]/git/file-diff.get.ts +109 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/progress.get.ts +36 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/tasks/[taskId]/commits.get.ts +146 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/tasks.get.ts +36 -0
- package/server/api/repos/[repoId]/prd/[prdSlug].get.ts +97 -0
- package/server/api/repos/[repoId]/prds.get.ts +85 -0
- package/server/api/repos/[repoId]/refresh-git-repos.post.ts +42 -0
- package/server/api/repos/[repoId].delete.ts +27 -0
- package/server/api/repos/index.get.ts +5 -0
- package/server/api/repos/index.post.ts +39 -0
- package/server/api/watch.get.ts +63 -0
- package/server/plugins/migrate-legacy-state.ts +19 -0
- package/server/tsconfig.json +3 -0
- package/server/utils/db.ts +169 -0
- package/server/utils/git.ts +478 -0
- package/server/utils/prd-state.ts +335 -0
- package/server/utils/repos.ts +322 -0
- package/server/utils/watcher.ts +179 -0
- 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
|
+
}
|