@civilization/dsh-git-workbench 0.0.0-stage → 0.4.1

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.
@@ -0,0 +1,1025 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import type {
4
+ GitBranchInfo,
5
+ GitBranchesSummary,
6
+ GitCommitDetail,
7
+ GitCommitItem,
8
+ GitDiffFile,
9
+ GitDiffSummary,
10
+ GitFileStatus,
11
+ GitFileStatusCode,
12
+ GitGraphLanePath,
13
+ GitOperationResult,
14
+ GitStatusSummary,
15
+ GitTagItem,
16
+ } from './types.ts'
17
+
18
+ const execFileAsync = promisify(execFile)
19
+
20
+ /** Timeout in milliseconds for git operations */
21
+ const GIT_TIMEOUT_MS = 15_000
22
+ /** Maximum stdout buffer for git diff / log output (8MB) */
23
+ const GIT_MAX_BUFFER = 8 * 1024 * 1024
24
+
25
+ /** Vibrant IDEA-grade color palette for DAG branches */
26
+ const LANE_PALETTE: readonly string[] = [
27
+ '#388bfd', // Blue
28
+ '#2ea043', // Green
29
+ '#f0883e', // Orange
30
+ '#a371f7', // Purple
31
+ '#db61a2', // Rose
32
+ '#39c5bb', // Cyan
33
+ '#f9c513', // Amber
34
+ ]
35
+
36
+ /**
37
+ * Execute a git command in the target directory safely.
38
+ * @param cwd - Working directory for the git execution.
39
+ * @param args - Subcommand and arguments to pass to git.
40
+ * @returns Raw stdout trimmed, or undefined if the execution failed.
41
+ */
42
+ export async function runGit(cwd: string, args: readonly string[]): Promise<string | undefined> {
43
+ try {
44
+ const { stdout } = await execFileAsync('git', args as string[], {
45
+ cwd,
46
+ timeout: GIT_TIMEOUT_MS,
47
+ maxBuffer: GIT_MAX_BUFFER,
48
+ windowsHide: true,
49
+ encoding: 'utf8',
50
+ })
51
+ return stdout.trim()
52
+ } catch {
53
+ return undefined
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Detailed execution result returning stdout, stderr, and success code.
59
+ * @param cwd - Working directory.
60
+ * @param args - Arguments.
61
+ * @returns Standard execution details.
62
+ */
63
+ export async function runGitDetailed(cwd: string, args: readonly string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
64
+ try {
65
+ const { stdout, stderr } = await execFileAsync('git', args as string[], {
66
+ cwd,
67
+ timeout: GIT_TIMEOUT_MS,
68
+ maxBuffer: GIT_MAX_BUFFER,
69
+ windowsHide: true,
70
+ encoding: 'utf8',
71
+ })
72
+ return { ok: true, stdout: stdout.trim(), stderr: stderr.trim() }
73
+ } catch (err: unknown) {
74
+ const error = err as { stdout?: string; stderr?: string; message?: string }
75
+ return {
76
+ ok: false,
77
+ stdout: (error.stdout ?? '').trim(),
78
+ stderr: (error.stderr || error.message || String(err)).trim(),
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Check if the target directory is inside a valid git repository worktree.
85
+ * @param cwd - Target directory.
86
+ * @returns Whether cwd is a git repository.
87
+ */
88
+ export async function checkIsGitRepo(cwd: string): Promise<boolean> {
89
+ const result = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'])
90
+ return result === 'true'
91
+ }
92
+
93
+ /**
94
+ * Get the absolute top-level directory of the git repository.
95
+ * @param cwd - Target directory.
96
+ * @returns Top-level directory path, or undefined.
97
+ */
98
+ export async function getGitRoot(cwd: string): Promise<string | undefined> {
99
+ const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
100
+ return root ? root.replaceAll('\\', '/') : undefined
101
+ }
102
+
103
+ /**
104
+ * Map porcelain status character to normalized status code.
105
+ * @param char - Status character from git porcelain.
106
+ * @returns Normalized status code.
107
+ */
108
+ function parseStatusCode(char: string): GitFileStatusCode {
109
+ switch (char) {
110
+ case 'A': return 'added'
111
+ case 'D': return 'deleted'
112
+ case 'R': return 'renamed'
113
+ case 'C': return 'copied'
114
+ case '?': return 'untracked'
115
+ case 'M':
116
+ default:
117
+ return 'modified'
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Parse one porcelain file status line.
123
+ * @param line - Raw line from `git status --porcelain=v1`.
124
+ * @returns Status entries for staged, unstaged, or untracked categories.
125
+ */
126
+ function parsePorcelainLine(line: string): { staged?: GitFileStatus; unstaged?: GitFileStatus; untracked?: GitFileStatus } {
127
+ if (line.length < 3) return {}
128
+ const x = line[0] ?? ' '
129
+ const y = line[1] ?? ' '
130
+ const rawPath = line.slice(3).trim()
131
+
132
+ // Handle renames: "oldPath -> newPath"
133
+ let path = rawPath
134
+ let originalPath: string | undefined
135
+ if (rawPath.includes(' -> ')) {
136
+ const parts = rawPath.split(' -> ')
137
+ originalPath = parts[0]?.replaceAll('"', '')
138
+ path = parts[1]?.replaceAll('"', '') ?? rawPath
139
+ } else {
140
+ path = rawPath.replaceAll('"', '')
141
+ }
142
+
143
+ // Untracked files
144
+ if (x === '?' && y === '?') {
145
+ return {
146
+ untracked: {
147
+ path,
148
+ status: 'untracked',
149
+ staged: false,
150
+ },
151
+ }
152
+ }
153
+
154
+ const result: { staged?: GitFileStatus; unstaged?: GitFileStatus } = {}
155
+
156
+ // Index (staged) change
157
+ if (x !== ' ' && x !== '?') {
158
+ result.staged = {
159
+ path,
160
+ ...originalPath !== undefined ? { originalPath } : {},
161
+ status: parseStatusCode(x),
162
+ staged: true,
163
+ }
164
+ }
165
+
166
+ // Working tree (unstaged) change
167
+ if (y !== ' ' && y !== '?') {
168
+ result.unstaged = {
169
+ path,
170
+ ...originalPath !== undefined ? { originalPath } : {},
171
+ status: parseStatusCode(y),
172
+ staged: false,
173
+ }
174
+ }
175
+
176
+ return result
177
+ }
178
+
179
+ /**
180
+ * Query full git repository status for the given directory.
181
+ * @param cwd - Target directory.
182
+ * @returns Complete status summary.
183
+ */
184
+ export async function getGitStatus(cwd: string): Promise<GitStatusSummary> {
185
+ const isRepo = await checkIsGitRepo(cwd)
186
+ if (!isRepo) {
187
+ return {
188
+ isRepo: false,
189
+ ahead: 0,
190
+ behind: 0,
191
+ staged: [],
192
+ unstaged: [],
193
+ untracked: [],
194
+ totalChanges: 0,
195
+ }
196
+ }
197
+
198
+ const rootPath = await getGitRoot(cwd)
199
+ const porcelain = await runGit(cwd, ['status', '--porcelain=v1', '-b'])
200
+
201
+ let branch = 'HEAD'
202
+ let upstream: string | undefined
203
+ let ahead = 0
204
+ let behind = 0
205
+
206
+ const staged: GitFileStatus[] = []
207
+ const unstaged: GitFileStatus[] = []
208
+ const untracked: GitFileStatus[] = []
209
+
210
+ if (porcelain) {
211
+ const lines = porcelain.split(/\r?\n/)
212
+ for (const line of lines) {
213
+ if (!line) continue
214
+
215
+ if (line.startsWith('## ')) {
216
+ // Parse branch line: "## master...origin/master [ahead 1, behind 2]"
217
+ const branchHeader = line.slice(3).trim()
218
+ const bracketIndex = branchHeader.indexOf(' [')
219
+ const branchPart = bracketIndex !== -1 ? branchHeader.slice(0, bracketIndex) : branchHeader
220
+
221
+ if (branchPart.includes('...')) {
222
+ const [b, u] = branchPart.split('...')
223
+ branch = b ?? 'HEAD'
224
+ upstream = u
225
+ } else {
226
+ branch = branchPart
227
+ }
228
+
229
+ if (bracketIndex !== -1) {
230
+ const aheadMatch = /ahead (\d+)/.exec(branchHeader)
231
+ if (aheadMatch?.[1]) ahead = Number.parseInt(aheadMatch[1], 10)
232
+ const behindMatch = /behind (\d+)/.exec(branchHeader)
233
+ if (behindMatch?.[1]) behind = Number.parseInt(behindMatch[1], 10)
234
+ }
235
+ } else {
236
+ const parsed = parsePorcelainLine(line)
237
+ if (parsed.staged) staged.push(parsed.staged)
238
+ if (parsed.unstaged) unstaged.push(parsed.unstaged)
239
+ if (parsed.untracked) untracked.push(parsed.untracked)
240
+ }
241
+ }
242
+ }
243
+
244
+ // Fetch head commit info
245
+ let headCommit: GitStatusSummary['headCommit']
246
+ const headRaw = await runGit(cwd, ['log', '-1', '--pretty=format:%H%x1f%h%x1f%s'])
247
+ if (headRaw) {
248
+ const [hash, shortHash, message] = headRaw.split('\x1f')
249
+ if (hash && shortHash && message !== undefined) {
250
+ headCommit = { hash, shortHash, message }
251
+ }
252
+ }
253
+
254
+ let totalCommitsCount: number | undefined
255
+ const countRaw = await runGit(cwd, ['rev-list', '--count', 'HEAD'])
256
+ if (countRaw) {
257
+ const parsed = Number.parseInt(countRaw.trim(), 10)
258
+ if (!Number.isNaN(parsed)) totalCommitsCount = parsed
259
+ }
260
+
261
+ return {
262
+ isRepo: true,
263
+ ...rootPath !== undefined ? { rootPath } : {},
264
+ branch,
265
+ ...upstream !== undefined ? { upstream } : {},
266
+ ahead,
267
+ behind,
268
+ ...headCommit !== undefined ? { headCommit } : {},
269
+ ...totalCommitsCount !== undefined ? { totalCommitsCount } : {},
270
+ staged,
271
+ unstaged,
272
+ untracked,
273
+ totalChanges: staged.length + unstaged.length + untracked.length,
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Parse refs from git %D format (e.g. "HEAD -> master, origin/master, tag: v0.1").
279
+ * @param refStr - Raw refs string.
280
+ * @returns Normalized list of clean ref names.
281
+ */
282
+ function parseCommitRefs(refStr: string): string[] {
283
+ if (!refStr.trim()) return []
284
+ const parts = refStr.split(',')
285
+ const refs: string[] = []
286
+ for (const part of parts) {
287
+ const trimmed = part.trim()
288
+ if (!trimmed) continue
289
+ if (trimmed.startsWith('HEAD -> ')) {
290
+ refs.push(trimmed.slice(8))
291
+ } else {
292
+ refs.push(trimmed)
293
+ }
294
+ }
295
+ return refs
296
+ }
297
+
298
+ /**
299
+ * Compute DAG topology lanes and vector paths for commits (IntelliJ IDEA style graph).
300
+ * Implements mainline pinning (trunk commits firmly anchored to lane 0) and smooth lane allocations.
301
+ * @param rawCommits - Chronological descending commits with parents.
302
+ * @returns Commits augmented with lane indices, lane colors, and connecting paths.
303
+ */
304
+ function buildDagTopology(rawCommits: {
305
+ hash: string
306
+ shortHash: string
307
+ author: string
308
+ authorEmail: string
309
+ relativeDate: string
310
+ date: string
311
+ subject: string
312
+ parents: string[]
313
+ refs: string[]
314
+ }[]): GitCommitItem[] {
315
+ const activeLanes: (string | null)[] = []
316
+ const result: GitCommitItem[] = []
317
+
318
+ for (const item of rawCommits) {
319
+ // 1. Locate current commit lane
320
+ let lane = activeLanes.indexOf(item.hash)
321
+
322
+ if (lane === -1) {
323
+ if (activeLanes.length === 0 || activeLanes[0] === null) {
324
+ // Mainline trunk slot (lane 0) is available
325
+ lane = 0
326
+ if (activeLanes.length === 0) activeLanes.push(null)
327
+ else activeLanes[0] = null
328
+ } else {
329
+ // Find first empty slot starting at lane 1, preserving lane 0 for mainline trunk
330
+ let emptySlot = -1
331
+ for (let l = 1; l < activeLanes.length; l++) {
332
+ if (activeLanes[l] === null) {
333
+ emptySlot = l
334
+ break
335
+ }
336
+ }
337
+ if (emptySlot !== -1) {
338
+ lane = emptySlot
339
+ } else {
340
+ lane = activeLanes.length
341
+ activeLanes.push(null)
342
+ }
343
+ }
344
+ }
345
+
346
+ const laneColor = LANE_PALETTE[lane % LANE_PALETTE.length] ?? '#388bfd'
347
+ // Clear current position
348
+ activeLanes[lane] = null
349
+
350
+ // 2. Map parents to target lanes
351
+ const paths: GitGraphLanePath[] = []
352
+ const [firstParent, ...otherParents] = item.parents
353
+
354
+ if (firstParent) {
355
+ const existingParentLane = activeLanes.indexOf(firstParent)
356
+ if (lane === 0) {
357
+ // Mainline commit: ensure first parent stays pinned on lane 0
358
+ if (existingParentLane !== -1 && existingParentLane !== 0) {
359
+ // If firstParent was tracked on another lane, pull it back to lane 0
360
+ activeLanes[existingParentLane] = null
361
+ paths.push({
362
+ fromLane: existingParentLane,
363
+ toLane: 0,
364
+ kind: 'merge',
365
+ color: laneColor,
366
+ })
367
+ }
368
+ activeLanes[0] = firstParent
369
+ paths.push({
370
+ fromLane: 0,
371
+ toLane: 0,
372
+ kind: 'straight',
373
+ color: laneColor,
374
+ })
375
+ } else if (existingParentLane !== -1) {
376
+ // Parent already tracked in another lane (e.g. merge back into mainline or sibling branch)
377
+ paths.push({
378
+ fromLane: lane,
379
+ toLane: existingParentLane,
380
+ kind: 'merge',
381
+ color: laneColor,
382
+ })
383
+ } else {
384
+ // Continue branch on current lane
385
+ activeLanes[lane] = firstParent
386
+ paths.push({
387
+ fromLane: lane,
388
+ toLane: lane,
389
+ kind: 'straight',
390
+ color: laneColor,
391
+ })
392
+ }
393
+ }
394
+
395
+ // Additional parents (merge commit)
396
+ for (const otherParent of otherParents) {
397
+ const existingLane = activeLanes.indexOf(otherParent)
398
+ if (existingLane !== -1) {
399
+ paths.push({
400
+ fromLane: lane,
401
+ toLane: existingLane,
402
+ kind: 'merge',
403
+ color: LANE_PALETTE[existingLane % LANE_PALETTE.length] ?? laneColor,
404
+ })
405
+ } else {
406
+ // Allocate a new lane for this merged-in branch (avoid lane 0)
407
+ let emptySlot = -1
408
+ for (let l = 1; l < activeLanes.length; l++) {
409
+ if (activeLanes[l] === null) {
410
+ emptySlot = l
411
+ break
412
+ }
413
+ }
414
+ const newLane = emptySlot !== -1 ? emptySlot : Math.max(1, activeLanes.length)
415
+ if (emptySlot !== -1) {
416
+ activeLanes[emptySlot] = otherParent
417
+ } else {
418
+ while (activeLanes.length < newLane) activeLanes.push(null)
419
+ activeLanes[newLane] = otherParent
420
+ }
421
+
422
+ const branchColor = LANE_PALETTE[newLane % LANE_PALETTE.length] ?? laneColor
423
+ paths.push({
424
+ fromLane: lane,
425
+ toLane: newLane,
426
+ kind: 'fork',
427
+ color: branchColor,
428
+ })
429
+ }
430
+ }
431
+
432
+ // Add pass-through straight lines for other active lanes
433
+ for (let l = 0; l < activeLanes.length; l++) {
434
+ if (l !== lane && activeLanes[l] !== null) {
435
+ paths.push({
436
+ fromLane: l,
437
+ toLane: l,
438
+ kind: 'straight',
439
+ color: LANE_PALETTE[l % LANE_PALETTE.length] ?? '#388bfd',
440
+ })
441
+ }
442
+ }
443
+
444
+ // Trim trailing empty lanes
445
+ while (activeLanes.length > 0 && activeLanes.at(-1) === null) {
446
+ activeLanes.pop()
447
+ }
448
+
449
+ result.push({
450
+ hash: item.hash,
451
+ shortHash: item.shortHash,
452
+ author: item.author,
453
+ authorEmail: item.authorEmail,
454
+ relativeDate: item.relativeDate,
455
+ date: item.date,
456
+ subject: item.subject,
457
+ parents: item.parents,
458
+ refs: item.refs,
459
+ lane,
460
+ laneColor,
461
+ paths,
462
+ })
463
+ }
464
+
465
+ return result
466
+ }
467
+
468
+ /**
469
+ * Query recent git commit history with DAG graph topology for the given directory.
470
+ * @param cwd - Target directory.
471
+ * @param maxCount - Maximum number of commits to retrieve (defaults to 50).
472
+ * @returns Ordered list of recent commits with topology paths.
473
+ */
474
+ export async function getGitLog(cwd: string, maxCount = 50): Promise<readonly GitCommitItem[]> {
475
+ const isRepo = await checkIsGitRepo(cwd)
476
+ if (!isRepo) return []
477
+
478
+ const raw = await runGit(cwd, [
479
+ 'log',
480
+ '-n',
481
+ String(maxCount),
482
+ '--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%ar%x1f%ad%x1f%s%x1f%P%x1f%D',
483
+ ])
484
+ if (!raw) return []
485
+
486
+ const rawCommits: {
487
+ hash: string
488
+ shortHash: string
489
+ author: string
490
+ authorEmail: string
491
+ relativeDate: string
492
+ date: string
493
+ subject: string
494
+ parents: string[]
495
+ refs: string[]
496
+ }[] = []
497
+
498
+ const lines = raw.split(/\r?\n/)
499
+ for (const line of lines) {
500
+ if (!line) continue
501
+ const parts = line.split('\x1f')
502
+ if (parts.length >= 7 && parts[0] && parts[1] && parts[2] && parts[3] && parts[4] && parts[5] && parts[6] !== undefined) {
503
+ const parentStr = parts[7] ?? ''
504
+ const parents = parentStr.trim() ? parentStr.trim().split(/\s+/) : []
505
+ const refStr = parts[8] ?? ''
506
+ const refs = parseCommitRefs(refStr)
507
+
508
+ rawCommits.push({
509
+ hash: parts[0],
510
+ shortHash: parts[1],
511
+ author: parts[2],
512
+ authorEmail: parts[3],
513
+ relativeDate: parts[4],
514
+ date: parts[5],
515
+ subject: parts[6],
516
+ parents,
517
+ refs,
518
+ })
519
+ }
520
+ }
521
+
522
+ return buildDagTopology(rawCommits)
523
+ }
524
+
525
+ /**
526
+ * Query details and changed files for a specific commit.
527
+ * @param cwd - Working directory.
528
+ * @param commitHash - Full or abbreviated commit hash.
529
+ * @returns Detailed commit information.
530
+ */
531
+ export async function getGitCommitDetails(cwd: string, commitHash: string): Promise<GitCommitDetail | undefined> {
532
+ const isRepo = await checkIsGitRepo(cwd)
533
+ if (!isRepo) return undefined
534
+
535
+ // 1. Get commit header metadata and body
536
+ const headRaw = await runGit(cwd, [
537
+ 'show',
538
+ '--no-patch',
539
+ '--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%ar%x1f%ad%x1f%s%x1f%P%x1f%D%x1f%b',
540
+ commitHash,
541
+ ])
542
+ if (!headRaw) return undefined
543
+
544
+ const parts = headRaw.split('\x1f')
545
+ if (parts.length < 7 || !parts[0] || !parts[1] || !parts[2] || !parts[3] || !parts[4] || !parts[5] || parts[6] === undefined) {
546
+ return undefined
547
+ }
548
+
549
+ const parentStr = parts[7] ?? ''
550
+ const parents = parentStr.trim() ? parentStr.trim().split(/\s+/) : []
551
+ const refStr = parts[8] ?? ''
552
+ const refs = parseCommitRefs(refStr)
553
+ const rawBody = (parts[9] ?? '').trim()
554
+ const body = rawBody.length > 0 ? rawBody : undefined
555
+
556
+ const commit: GitCommitItem = {
557
+ hash: parts[0],
558
+ shortHash: parts[1],
559
+ author: parts[2],
560
+ authorEmail: parts[3],
561
+ relativeDate: parts[4],
562
+ date: parts[5],
563
+ subject: parts[6],
564
+ ...body !== undefined ? { body } : {},
565
+ parents,
566
+ refs,
567
+ }
568
+
569
+ // 2. Get changed files with numstat
570
+ const numstatRaw = await runGit(cwd, [
571
+ 'show',
572
+ '--numstat',
573
+ '--format=',
574
+ '-M',
575
+ commitHash,
576
+ ])
577
+
578
+ const files: {
579
+ path: string
580
+ oldPath?: string
581
+ status: GitFileStatusCode
582
+ additions: number
583
+ deletions: number
584
+ }[] = []
585
+
586
+ let totalAdditions = 0
587
+ let totalDeletions = 0
588
+
589
+ if (numstatRaw) {
590
+ const lines = numstatRaw.split(/\r?\n/)
591
+ for (const line of lines) {
592
+ if (!line.trim()) continue
593
+ const match = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line)
594
+ if (match && match[1] && match[2] && match[3]) {
595
+ const adds = match[1] === '-' ? 0 : Number.parseInt(match[1], 10)
596
+ const dels = match[2] === '-' ? 0 : Number.parseInt(match[2], 10)
597
+ const rawPath = match[3]
598
+
599
+ let path = rawPath
600
+ let oldPath: string | undefined
601
+ let status: GitFileStatusCode = 'modified'
602
+
603
+ if (rawPath.includes(' => ')) {
604
+ status = 'renamed'
605
+ // Formats like "src/{old.ts => new.ts}" or "old.ts => new.ts"
606
+ const braceMatch = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(rawPath)
607
+ const hasBraceGroups = braceMatch && braceMatch[1] !== undefined
608
+ && braceMatch[2] !== undefined && braceMatch[3] !== undefined && braceMatch[4] !== undefined
609
+ if (hasBraceGroups) {
610
+ oldPath = `${braceMatch[1]}${braceMatch[2]}${braceMatch[4]}`
611
+ path = `${braceMatch[1]}${braceMatch[3]}${braceMatch[4]}`
612
+ } else {
613
+ const split = rawPath.split(' => ')
614
+ oldPath = split[0]?.trim()
615
+ path = split[1]?.trim() ?? rawPath
616
+ }
617
+ }
618
+
619
+ totalAdditions += adds
620
+ totalDeletions += dels
621
+
622
+ files.push({
623
+ path,
624
+ ...oldPath !== undefined ? { oldPath } : {},
625
+ status,
626
+ additions: adds,
627
+ deletions: dels,
628
+ })
629
+ }
630
+ }
631
+ }
632
+
633
+ return {
634
+ commit,
635
+ ...body !== undefined ? { body } : {},
636
+ ...parents.length > 0 ? { parents } : {},
637
+ files,
638
+ totalAdditions,
639
+ totalDeletions,
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Query git diff for specified file or current directory.
645
+ * @param cwd - Target directory.
646
+ * @param options - Diff parameters.
647
+ * @returns Complete diff text and per-file parsed statistics.
648
+ */
649
+ export async function getGitDiff(
650
+ cwd: string,
651
+ options?: { path?: string; staged?: boolean; commit?: string },
652
+ ): Promise<GitDiffSummary> {
653
+ const isRepo = await checkIsGitRepo(cwd)
654
+ if (!isRepo) return { files: [], rawDiff: '' }
655
+
656
+ const args: string[] = []
657
+
658
+ if (options?.commit) {
659
+ args.push('show', '--format=', '--patch', options.commit)
660
+ } else {
661
+ args.push('diff')
662
+ if (options?.staged) {
663
+ args.push('--staged')
664
+ }
665
+ }
666
+
667
+ if (options?.path) {
668
+ args.push('--', options.path)
669
+ }
670
+
671
+ const rawDiff = (await runGit(cwd, args)) ?? ''
672
+ if (!rawDiff) return { files: [], rawDiff: '' }
673
+
674
+ const files: GitDiffFile[] = []
675
+ const fileChunks = rawDiff.split(/^diff --git /m)
676
+
677
+ for (const chunk of fileChunks) {
678
+ if (!chunk.trim()) continue
679
+ const firstLineEnd = chunk.indexOf('\n')
680
+ const firstLine = firstLineEnd !== -1 ? chunk.slice(0, firstLineEnd).trim() : chunk.trim()
681
+ const match = /a\/(.+?) b\/(.+?)$/.exec(firstLine)
682
+ const filePath = match?.[2] ?? match?.[1] ?? 'unknown'
683
+ const oldPath = match?.[1] !== match?.[2] ? match?.[1] : undefined
684
+
685
+ let additions = 0
686
+ let deletions = 0
687
+ const lines = chunk.split('\n')
688
+ for (const l of lines) {
689
+ if (l.startsWith('+') && !l.startsWith('+++')) additions++
690
+ else if (l.startsWith('-') && !l.startsWith('---')) deletions++
691
+ }
692
+
693
+ files.push({
694
+ path: filePath,
695
+ ...oldPath !== undefined ? { oldPath } : {},
696
+ status: 'modified',
697
+ diffText: `diff --git ${chunk}`,
698
+ additions,
699
+ deletions,
700
+ })
701
+ }
702
+
703
+ return { files, rawDiff }
704
+ }
705
+
706
+ /**
707
+ * Query local and remote branches.
708
+ * @param cwd - Target directory.
709
+ * @returns Aggregated branch summary.
710
+ */
711
+ export async function getGitAllBranches(cwd: string): Promise<GitBranchesSummary> {
712
+ const isRepo = await checkIsGitRepo(cwd)
713
+ if (!isRepo) return { local: [], remote: [] }
714
+
715
+ const raw = await runGit(cwd, ['branch', '-a', '-vv', '--list'])
716
+ if (!raw) return { local: [], remote: [] }
717
+
718
+ const local: GitBranchInfo[] = []
719
+ const remote: GitBranchInfo[] = []
720
+ let current: string | undefined
721
+
722
+ const lines = raw.split(/\r?\n/)
723
+ for (const line of lines) {
724
+ if (!line.trim()) continue
725
+ const isCurrent = line.startsWith('*')
726
+ const cleanLine = line.slice(2).trim()
727
+
728
+ // Skip detached HEAD or symbolic pointers like remotes/origin/HEAD -> origin/master
729
+ if (cleanLine.includes('->')) continue
730
+
731
+ const spaceIndex = cleanLine.indexOf(' ')
732
+ const fullName = spaceIndex !== -1 ? cleanLine.slice(0, spaceIndex) : cleanLine
733
+
734
+ let upstream: string | undefined
735
+ let ahead: number | undefined
736
+ let behind: number | undefined
737
+
738
+ const bracketStart = cleanLine.indexOf('[')
739
+ const bracketEnd = cleanLine.indexOf(']')
740
+ if (bracketStart !== -1 && bracketEnd > bracketStart) {
741
+ const info = cleanLine.slice(bracketStart + 1, bracketEnd)
742
+ const colonIndex = info.indexOf(':')
743
+ upstream = colonIndex !== -1 ? info.slice(0, colonIndex).trim() : info.trim()
744
+
745
+ const aheadMatch = /ahead (\d+)/.exec(info)
746
+ if (aheadMatch?.[1]) ahead = Number.parseInt(aheadMatch[1], 10)
747
+ const behindMatch = /behind (\d+)/.exec(info)
748
+ if (behindMatch?.[1]) behind = Number.parseInt(behindMatch[1], 10)
749
+ }
750
+
751
+ if (isCurrent) {
752
+ current = fullName
753
+ }
754
+
755
+ if (fullName.startsWith('remotes/')) {
756
+ const remoteName = fullName.replace(/^remotes\//, '')
757
+ remote.push({
758
+ name: remoteName,
759
+ isCurrent: false,
760
+ })
761
+ } else {
762
+ local.push({
763
+ name: fullName,
764
+ isCurrent,
765
+ ...upstream !== undefined ? { upstream } : {},
766
+ ...ahead !== undefined ? { ahead } : {},
767
+ ...behind !== undefined ? { behind } : {},
768
+ })
769
+ }
770
+ }
771
+
772
+ return {
773
+ ...current !== undefined ? { current } : {},
774
+ local,
775
+ remote,
776
+ }
777
+ }
778
+
779
+
780
+ /**
781
+ * Query git local branches (legacy helper).
782
+ * @param cwd - Target directory.
783
+ * @returns List of branch information.
784
+ */
785
+ export async function getGitBranches(cwd: string): Promise<readonly GitBranchInfo[]> {
786
+ const summary = await getGitAllBranches(cwd)
787
+ return summary.local
788
+ }
789
+
790
+ /**
791
+ * Checkout an existing branch or create a new branch.
792
+ * @param cwd - Target directory.
793
+ * @param branch - Branch name to switch to.
794
+ * @param createNew - Whether to create a new branch (-b).
795
+ * @returns Operation result.
796
+ */
797
+ export async function gitCheckout(cwd: string, branch: string, createNew?: boolean): Promise<GitOperationResult> {
798
+ const cleanBranch = branch.trim()
799
+ if (!cleanBranch) return { success: false, message: 'Branch name cannot be empty' }
800
+
801
+ const args = createNew ? ['checkout', '-b', cleanBranch] : ['checkout', cleanBranch]
802
+ const res = await runGitDetailed(cwd, args)
803
+ return {
804
+ success: res.ok,
805
+ message: res.ok ? `Checked out branch ${cleanBranch}` : res.stderr || 'Checkout failed',
806
+ output: res.stdout || res.stderr,
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Stage files to index.
812
+ * @param cwd - Target directory.
813
+ * @param paths - Specific file paths or 'all'.
814
+ * @returns Operation result.
815
+ */
816
+ export async function gitStage(cwd: string, paths: readonly string[] | 'all'): Promise<GitOperationResult> {
817
+ const args = paths === 'all' ? ['add', '-A'] : ['add', '--', ...paths]
818
+ const res = await runGitDetailed(cwd, args)
819
+ return {
820
+ success: res.ok,
821
+ message: res.ok ? 'Files staged successfully' : res.stderr || 'Stage failed',
822
+ output: res.stdout || res.stderr,
823
+ }
824
+ }
825
+
826
+ /**
827
+ * Unstage files from index.
828
+ * @param cwd - Target directory.
829
+ * @param paths - Specific file paths or 'all'.
830
+ * @returns Operation result.
831
+ */
832
+ export async function gitUnstage(cwd: string, paths: readonly string[] | 'all'): Promise<GitOperationResult> {
833
+ const args = paths === 'all' ? ['reset', 'HEAD'] : ['reset', 'HEAD', '--', ...paths]
834
+ const res = await runGitDetailed(cwd, args)
835
+ return {
836
+ success: res.ok,
837
+ message: res.ok ? 'Files unstaged successfully' : res.stderr || 'Unstage failed',
838
+ output: res.stdout || res.stderr,
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Discard working tree changes for specified files.
844
+ * @param cwd - Target directory.
845
+ * @param paths - Specific file paths to discard.
846
+ * @returns Operation result.
847
+ */
848
+ export async function gitDiscard(cwd: string, paths: readonly string[]): Promise<GitOperationResult> {
849
+ if (paths.length === 0) return { success: true }
850
+ // 1. Restore tracked changes
851
+ const resRestore = await runGitDetailed(cwd, ['checkout', '--', ...paths])
852
+ // 2. Clean untracked files if any
853
+ await runGitDetailed(cwd, ['clean', '-f', '--', ...paths])
854
+ return {
855
+ success: resRestore.ok,
856
+ message: resRestore.ok ? 'Changes discarded successfully' : resRestore.stderr || 'Discard failed',
857
+ output: resRestore.stdout || resRestore.stderr,
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Commit staged changes with message.
863
+ * @param cwd - Target directory.
864
+ * @param message - Commit message.
865
+ * @returns Operation result.
866
+ */
867
+ export async function gitCommit(cwd: string, message: string): Promise<GitOperationResult> {
868
+ const trimmed = message.trim()
869
+ if (!trimmed) return { success: false, message: 'Commit message cannot be empty' }
870
+ const res = await runGitDetailed(cwd, ['commit', '-m', trimmed])
871
+ return {
872
+ success: res.ok,
873
+ message: res.ok ? 'Changes committed successfully' : res.stderr || 'Commit failed',
874
+ output: res.stdout || res.stderr,
875
+ }
876
+ }
877
+
878
+ /**
879
+ * Fetch updates from remotes.
880
+ * @param cwd - Target directory.
881
+ * @returns Operation result.
882
+ */
883
+ export async function gitFetch(cwd: string): Promise<GitOperationResult> {
884
+ const res = await runGitDetailed(cwd, ['fetch', '--all', '--prune'])
885
+ return {
886
+ success: res.ok,
887
+ message: res.ok ? 'Fetched successfully' : res.stderr || 'Fetch failed',
888
+ output: res.stdout || res.stderr,
889
+ }
890
+ }
891
+
892
+ /**
893
+ * Pull remote changes into current branch.
894
+ * @param cwd - Target directory.
895
+ * @returns Operation result.
896
+ */
897
+ export async function gitPull(cwd: string): Promise<GitOperationResult> {
898
+ const res = await runGitDetailed(cwd, ['pull'])
899
+ return {
900
+ success: res.ok,
901
+ message: res.ok ? 'Pulled successfully' : res.stderr || 'Pull failed',
902
+ output: res.stdout || res.stderr,
903
+ }
904
+ }
905
+
906
+ /**
907
+ * Push local commits to remote upstream.
908
+ * @param cwd - Target directory.
909
+ * @returns Operation result.
910
+ */
911
+ export async function gitPush(cwd: string): Promise<GitOperationResult> {
912
+ const res = await runGitDetailed(cwd, ['push'])
913
+ return {
914
+ success: res.ok,
915
+ message: res.ok ? 'Pushed successfully' : res.stderr || 'Push failed',
916
+ output: res.stdout || res.stderr,
917
+ }
918
+ }
919
+
920
+ /**
921
+ * Query all git tags in the repository sorted by creation date descending.
922
+ * @param cwd - Target directory.
923
+ * @returns Ordered list of Git tags with commit hashes and metadata.
924
+ */
925
+ export async function getGitTags(cwd: string): Promise<readonly GitTagItem[]> {
926
+ const isRepo = await checkIsGitRepo(cwd)
927
+ if (!isRepo) return []
928
+
929
+ const raw = await runGit(cwd, [
930
+ 'for-each-ref',
931
+ '--sort=-creatordate',
932
+ 'refs/tags',
933
+ '--format=' +
934
+ '%(refname:short)\t%(objectname)\t%(*objectname)\t' +
935
+ '%(creatordate:iso)\t%(creatordate:relative)\t%(contents:subject)\t' +
936
+ '%(taggername)\t%(authorname)',
937
+ ])
938
+ if (!raw) return []
939
+
940
+ const tags: GitTagItem[] = []
941
+ const lines = raw.split(/\r?\n/)
942
+
943
+ for (const line of lines) {
944
+ if (!line.trim()) continue
945
+ const parts = line.split('\t')
946
+ if (parts.length >= 6 && parts[0]) {
947
+ const name = parts[0]
948
+ const objectName = parts[1] ?? ''
949
+ const peeledHash = (parts[2] ?? '').trim()
950
+ const commitHash = peeledHash || objectName
951
+ const isAnnotated = Boolean(peeledHash)
952
+ const date = parts[3] ?? ''
953
+ const relativeDate = parts[4] ?? ''
954
+ const message = parts[5] ?? ''
955
+ const author = parts[6] || parts[7] || 'Unknown'
956
+
957
+ tags.push({
958
+ name,
959
+ commitHash,
960
+ shortHash: commitHash.slice(0, 7),
961
+ date,
962
+ relativeDate,
963
+ message,
964
+ author,
965
+ isAnnotated,
966
+ })
967
+ }
968
+ }
969
+
970
+ return tags
971
+ }
972
+
973
+ /**
974
+ * Create a new git tag at the specified commit or HEAD.
975
+ * @param cwd - Target directory.
976
+ * @param name - Tag name (e.g. 'v1.0.0').
977
+ * @param targetCommit - Optional commit hash (defaults to HEAD).
978
+ * @param message - Optional annotation message. If provided, creates an annotated tag (-a).
979
+ * @returns Operation result.
980
+ */
981
+ export async function gitCreateTag(
982
+ cwd: string,
983
+ name: string,
984
+ targetCommit?: string,
985
+ message?: string,
986
+ ): Promise<GitOperationResult> {
987
+ const cleanName = name.trim()
988
+ if (!cleanName) return { success: false, message: 'Tag name cannot be empty' }
989
+
990
+ const args = ['tag']
991
+ if (message && message.trim()) {
992
+ args.push('-a', cleanName, '-m', message.trim())
993
+ } else {
994
+ args.push(cleanName)
995
+ }
996
+
997
+ if (targetCommit && targetCommit.trim()) {
998
+ args.push(targetCommit.trim())
999
+ }
1000
+
1001
+ const res = await runGitDetailed(cwd, args)
1002
+ return {
1003
+ success: res.ok,
1004
+ message: res.ok ? `Tag '${cleanName}' created successfully` : res.stderr || 'Failed to create tag',
1005
+ output: res.stdout || res.stderr,
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * Delete a git tag.
1011
+ * @param cwd - Target directory.
1012
+ * @param name - Tag name to delete.
1013
+ * @returns Operation result.
1014
+ */
1015
+ export async function gitDeleteTag(cwd: string, name: string): Promise<GitOperationResult> {
1016
+ const cleanName = name.trim()
1017
+ if (!cleanName) return { success: false, message: 'Tag name cannot be empty' }
1018
+
1019
+ const res = await runGitDetailed(cwd, ['tag', '-d', cleanName])
1020
+ return {
1021
+ success: res.ok,
1022
+ message: res.ok ? `Tag '${cleanName}' deleted successfully` : res.stderr || 'Failed to delete tag',
1023
+ output: res.stdout || res.stderr,
1024
+ }
1025
+ }