@civilization/dsh-git-workbench 0.0.0-stage → 0.4.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/LICENSE +201 -0
- package/README.md +119 -2
- package/cordis.patch.yml +3 -0
- package/icon.svg +26 -0
- package/lib/client.js +2943 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1274 -0
- package/package.json +91 -4
- package/src/client/GitBody.module.css +1283 -0
- package/src/client/GitBody.tsx +1496 -0
- package/src/client/GitBranchPill.module.css +292 -0
- package/src/client/GitBranchPill.tsx +368 -0
- package/src/client/GitTitle.tsx +27 -0
- package/src/client/definition.tsx +63 -0
- package/src/client/git-helpers.ts +351 -0
- package/src/client/index.ts +221 -0
- package/src/client/locales.ts +183 -0
- package/src/css.d.ts +4 -0
- package/src/git-executor.ts +1025 -0
- package/src/index.ts +220 -0
- package/src/tool.ts +299 -0
- package/src/types.ts +187 -0
|
@@ -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
|
+
}
|