dsh-git-branch-manage 1.0.2 → 1.0.4
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/lib/client.js +2157 -191
- package/lib/git.d.ts +151 -3
- package/lib/git.js +326 -23
- package/lib/index.js +22 -2
- package/lib/routes.d.ts +91 -3
- package/lib/routes.js +256 -5
- package/lib/wire.d.ts +156 -0
- package/lib/wire.js +17 -0
- package/package.json +1 -1
package/lib/git.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface ExecResult {
|
|
|
18
18
|
*/
|
|
19
19
|
export type Exec = (file: string, args: readonly string[], options: {
|
|
20
20
|
cwd: string;
|
|
21
|
+
timeoutMs?: number;
|
|
21
22
|
}) => Promise<ExecResult>;
|
|
22
23
|
/** Failure of one git invocation: non-zero exit with the stderr text. */
|
|
23
24
|
export declare class GitError extends Error {
|
|
@@ -48,11 +49,24 @@ export interface RepoFacts {
|
|
|
48
49
|
/** Branch checked out by the queried directory. */
|
|
49
50
|
currentBranch: string;
|
|
50
51
|
branches: BranchEntry[];
|
|
52
|
+
/** EVERY remote branch (twins included, `<remote>/HEAD` and bare leftover
|
|
53
|
+
* refs dropped) — the git panel's remote tree lists the full remote view,
|
|
54
|
+
* while {@link RepoFacts.branches} keeps the picker's remote-only dedupe. */
|
|
55
|
+
remoteBranches: BranchEntry[];
|
|
51
56
|
worktrees: WorktreeEntry[];
|
|
52
57
|
}
|
|
58
|
+
/** Default hard timeout for one git call: generous for every LOCAL
|
|
59
|
+
* operation (status/log/commit/worktree) while still bounding hangs. */
|
|
60
|
+
export declare const GIT_TIMEOUT_MS = 20000;
|
|
61
|
+
/** Network operations (fetch/push) get a far wider budget: a slow link with
|
|
62
|
+
* a real pack transfer outruns the local default, and a killed push may have
|
|
63
|
+
* ALREADY updated the remote refs — an error toast for a push that actually
|
|
64
|
+
* landed. 120s still bounds a hung credential prompt. */
|
|
65
|
+
export declare const NETWORK_TIMEOUT_MS = 120000;
|
|
53
66
|
/** Real executor over node:child_process with a hard timeout. */
|
|
54
67
|
export declare function childProcessExec(file: string, args: readonly string[], options: {
|
|
55
68
|
cwd: string;
|
|
69
|
+
timeoutMs?: number;
|
|
56
70
|
}): Promise<ExecResult>;
|
|
57
71
|
/** Directory-existence seam over the parsed worktree registrations. */
|
|
58
72
|
export type DirExists = (path: string) => boolean;
|
|
@@ -134,15 +148,17 @@ export declare function cutoutBranchName(exec: Exec, repoRoot: string, base: str
|
|
|
134
148
|
*/
|
|
135
149
|
export declare function addWorktreeCutout(exec: Exec, repoRoot: string, base: string, newBranch: string, targetPath: string): Promise<void>;
|
|
136
150
|
/**
|
|
137
|
-
* In-place branch switch of
|
|
151
|
+
* In-place branch switch of one checkout. A remote display name relies
|
|
138
152
|
* on git's dwim: `git switch <local>` creates the tracking branch when
|
|
139
153
|
* exactly one remote has it.
|
|
140
154
|
* @param exec - executor seam.
|
|
141
|
-
* @param
|
|
155
|
+
* @param checkoutDir - the directory whose OWN checkout moves: the queried
|
|
156
|
+
* session's toplevel, so a linked worktree switches itself rather than the
|
|
157
|
+
* main checkout. `worktree prune` still runs here (it is repo-wide).
|
|
142
158
|
* @param branch - branch display name.
|
|
143
159
|
* @returns the local branch name now checked out.
|
|
144
160
|
*/
|
|
145
|
-
export declare function switchBranch(exec: Exec,
|
|
161
|
+
export declare function switchBranch(exec: Exec, checkoutDir: string, branch: string): Promise<string>;
|
|
146
162
|
/**
|
|
147
163
|
* Create a NEW branch. Three shapes:
|
|
148
164
|
*
|
|
@@ -343,3 +359,135 @@ export declare const fsExcludeSeams: ExcludeSeams;
|
|
|
343
359
|
* failure text otherwise.
|
|
344
360
|
*/
|
|
345
361
|
export declare function appendWorktreeExclude(seams: ExcludeSeams, repoRoot: string): Promise<string | undefined>;
|
|
362
|
+
/** Working-tree facts of POST /changes. */
|
|
363
|
+
export interface WorkingChanges {
|
|
364
|
+
/** One row per changed path, in `git status --porcelain` order. */
|
|
365
|
+
changes: Array<{
|
|
366
|
+
path: string;
|
|
367
|
+
oldPath?: string;
|
|
368
|
+
staged?: 'A' | 'M' | 'D' | 'R' | 'C' | 'T';
|
|
369
|
+
unstaged?: 'A' | 'M' | 'D' | 'T' | 'U';
|
|
370
|
+
untracked?: boolean;
|
|
371
|
+
}>;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Parse one `git status --porcelain=v1 -z` byte stream into change rows.
|
|
375
|
+
* NUL-separated so spaces and CJK in paths survive; a rename/copy entry is
|
|
376
|
+
* followed by its ORIGINAL path as the next NUL field. XY columns: staged
|
|
377
|
+
* (X) and unstaged (Y); `??` marks untracked; the unmerged pairs collapse
|
|
378
|
+
* to a conflict row (`unstaged: 'U'`).
|
|
379
|
+
*/
|
|
380
|
+
export declare function parseStatusPorcelainZ(out: string): WorkingChanges['changes'];
|
|
381
|
+
/**
|
|
382
|
+
* Working-tree changes of the queried checkout: `git status --porcelain=v1 -z`
|
|
383
|
+
* with `core.quotepath=false` so CJK paths arrive unescaped.
|
|
384
|
+
* @param exec - executor seam.
|
|
385
|
+
* @param cwd - the checkout directory (worktree toplevel).
|
|
386
|
+
*/
|
|
387
|
+
export declare function workingChanges(exec: Exec, cwd: string): Promise<WorkingChanges>;
|
|
388
|
+
/** Guard for one (un)stage path: relative, no `..` escape, no drive root. */
|
|
389
|
+
export declare function isStageablePath(value: string): boolean;
|
|
390
|
+
/**
|
|
391
|
+
* Stage or unstage paths of the queried checkout. Staging is `git add`;
|
|
392
|
+
* unstaging is `git reset -q HEAD --` which also answers the two tolerant
|
|
393
|
+
* cases (an untracked path not in the index, an unborn HEAD) with a silent
|
|
394
|
+
* success — both were smoke-verified.
|
|
395
|
+
* @param exec - executor seam.
|
|
396
|
+
* @param cwd - the checkout directory.
|
|
397
|
+
* @param paths - relative paths (validated upstream via {@link isStageablePath}).
|
|
398
|
+
* @param stage - true to stage, false to unstage.
|
|
399
|
+
*/
|
|
400
|
+
export declare function stagePaths(exec: Exec, cwd: string, paths: readonly string[], stage: boolean): Promise<void>;
|
|
401
|
+
/** Outcome of {@link commitIndex}. */
|
|
402
|
+
export interface CommitOutcome {
|
|
403
|
+
hash: string;
|
|
404
|
+
subject: string;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Commit the index of the queried checkout (`git commit -m`). Hooks run as
|
|
408
|
+
* usual; an empty index is git's own refusal and surfaces as a GitError.
|
|
409
|
+
* @param exec - executor seam.
|
|
410
|
+
* @param cwd - the checkout directory.
|
|
411
|
+
* @param message - non-empty commit message (validated upstream).
|
|
412
|
+
*/
|
|
413
|
+
export declare function commitIndex(exec: Exec, cwd: string, message: string): Promise<CommitOutcome>;
|
|
414
|
+
/** Parse `git log` output produced with {@link LOG_FORMAT}. */
|
|
415
|
+
export declare function parseLogOutput(out: string): CommitEntryRaw[];
|
|
416
|
+
/** One parsed log row, before any client shaping. */
|
|
417
|
+
export interface CommitEntryRaw {
|
|
418
|
+
hash: string;
|
|
419
|
+
short: string;
|
|
420
|
+
authorName: string;
|
|
421
|
+
authorEmail: string;
|
|
422
|
+
date: number;
|
|
423
|
+
parents: string[];
|
|
424
|
+
subject: string;
|
|
425
|
+
body: string;
|
|
426
|
+
refs: string[];
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Paginated history of the queried checkout. `ref` is verified with
|
|
430
|
+
* `rev-parse --verify` first, so an arbitrary string can never reach the log
|
|
431
|
+
* arguments as anything but a resolvable rev. An unborn checkout (no commits
|
|
432
|
+
* yet) answers an empty page instead of an error.
|
|
433
|
+
* @param exec - executor seam.
|
|
434
|
+
* @param cwd - the checkout directory.
|
|
435
|
+
* @param limit - page size (1..200, validated upstream).
|
|
436
|
+
* @param skip - offset (>= 0, validated upstream).
|
|
437
|
+
* @param ref - optional starting rev; absent = the checkout's HEAD.
|
|
438
|
+
*/
|
|
439
|
+
export declare function logHistory(exec: Exec, cwd: string, limit: number, skip: number, ref?: string): Promise<CommitEntryRaw[]>;
|
|
440
|
+
/** One file row of {@link commitFiles}. */
|
|
441
|
+
export interface CommitFileRow {
|
|
442
|
+
path: string;
|
|
443
|
+
oldPath?: string;
|
|
444
|
+
status: 'A' | 'M' | 'D' | 'R' | 'C' | 'T';
|
|
445
|
+
insertions?: number;
|
|
446
|
+
deletions?: number;
|
|
447
|
+
binary?: boolean;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Parse one `git diff --name-status -M` output block (tabs between fields).
|
|
451
|
+
* R/C rows carry `score\told\tnew`; plain rows `status\tpath`.
|
|
452
|
+
*/
|
|
453
|
+
export declare function parseNameStatus(out: string): Array<{
|
|
454
|
+
path: string;
|
|
455
|
+
oldPath?: string;
|
|
456
|
+
status: CommitFileRow['status'];
|
|
457
|
+
}>;
|
|
458
|
+
/**
|
|
459
|
+
* Parse one `git diff --numstat -M` output block: `ins\tdel\tpath`, `-` for a
|
|
460
|
+
* binary file's counts. Only the COUNTS are consumed — paths (whose rename
|
|
461
|
+
* form `old => new` / `prefix{old => new}suffix` is ambiguous) come from the
|
|
462
|
+
* aligned name-status block instead.
|
|
463
|
+
*/
|
|
464
|
+
export declare function parseNumstatCounts(out: string): Array<{
|
|
465
|
+
insertions: number | undefined;
|
|
466
|
+
deletions: number | undefined;
|
|
467
|
+
}>;
|
|
468
|
+
/**
|
|
469
|
+
* The files one commit changed, of the queried checkout. The hash is
|
|
470
|
+
* verified with `rev-parse --verify` first; status comes from
|
|
471
|
+
* `--name-status -M`, the +/- line counts from the index-aligned
|
|
472
|
+
* `--numstat -M` block.
|
|
473
|
+
* @param exec - executor seam.
|
|
474
|
+
* @param cwd - the checkout directory.
|
|
475
|
+
* @param hash - the commit hash (validated upstream, verified here).
|
|
476
|
+
*/
|
|
477
|
+
export declare function commitFiles(exec: Exec, cwd: string, hash: string): Promise<CommitFileRow[]>;
|
|
478
|
+
/** Cap for the diff text handed to the AI route (characters). */
|
|
479
|
+
export declare const AI_DIFF_CAP = 12000;
|
|
480
|
+
/**
|
|
481
|
+
* The diff text the AI commit-message route sees: the staged diff when the
|
|
482
|
+
* index is non-empty, otherwise the unstaged diff plus untracked paths —
|
|
483
|
+
* an AI message for nothing staged should describe what WOULD be committed.
|
|
484
|
+
* Truncated to {@link AI_DIFF_CAP} characters; also collects the last
|
|
485
|
+
* subjects so the model can follow the repository's own style.
|
|
486
|
+
* @param exec - executor seam.
|
|
487
|
+
* @param cwd - the checkout directory.
|
|
488
|
+
*/
|
|
489
|
+
export declare function collectAiDiff(exec: Exec, cwd: string): Promise<{
|
|
490
|
+
diff: string;
|
|
491
|
+
recentSubjects: string[];
|
|
492
|
+
staged: boolean;
|
|
493
|
+
}>;
|
package/lib/git.js
CHANGED
|
@@ -21,10 +21,18 @@ export class GitError extends Error {
|
|
|
21
21
|
this.name = 'GitError';
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
/** Default hard timeout for one git call: generous for every LOCAL
|
|
25
|
+
* operation (status/log/commit/worktree) while still bounding hangs. */
|
|
26
|
+
export const GIT_TIMEOUT_MS = 20_000;
|
|
27
|
+
/** Network operations (fetch/push) get a far wider budget: a slow link with
|
|
28
|
+
* a real pack transfer outruns the local default, and a killed push may have
|
|
29
|
+
* ALREADY updated the remote refs — an error toast for a push that actually
|
|
30
|
+
* landed. 120s still bounds a hung credential prompt. */
|
|
31
|
+
export const NETWORK_TIMEOUT_MS = 120_000;
|
|
24
32
|
/** Real executor over node:child_process with a hard timeout. */
|
|
25
33
|
export function childProcessExec(file, args, options) {
|
|
26
34
|
return new Promise((resolvePromise) => {
|
|
27
|
-
execFile(file, args, { cwd: options.cwd, encoding: 'utf8', timeout:
|
|
35
|
+
execFile(file, args, { cwd: options.cwd, encoding: 'utf8', timeout: options.timeoutMs ?? GIT_TIMEOUT_MS, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
28
36
|
const code = error === null ? 0 : typeof error.code === 'number' ? error.code : 1;
|
|
29
37
|
resolvePromise({
|
|
30
38
|
code,
|
|
@@ -36,8 +44,8 @@ export function childProcessExec(file, args, options) {
|
|
|
36
44
|
});
|
|
37
45
|
}
|
|
38
46
|
/** Run one git command; a non-zero exit raises {@link GitError} with stderr. */
|
|
39
|
-
async function git(exec, cwd, args) {
|
|
40
|
-
const result = await exec('git', args, { cwd });
|
|
47
|
+
async function git(exec, cwd, args, timeoutMs) {
|
|
48
|
+
const result = await exec('git', args, { cwd, ...(timeoutMs === undefined ? {} : { timeoutMs }) });
|
|
41
49
|
if (result.code !== 0) {
|
|
42
50
|
const diagnostic = result.stderr.trim() !== ''
|
|
43
51
|
? result.stderr
|
|
@@ -49,8 +57,8 @@ async function git(exec, cwd, args) {
|
|
|
49
57
|
return result.stdout;
|
|
50
58
|
}
|
|
51
59
|
/** Run one git command, mapping a non-zero exit to undefined instead of throwing. */
|
|
52
|
-
async function gitMaybe(exec, cwd, args) {
|
|
53
|
-
const result = await exec('git', args, { cwd });
|
|
60
|
+
async function gitMaybe(exec, cwd, args, timeoutMs) {
|
|
61
|
+
const result = await exec('git', args, { cwd, ...(timeoutMs === undefined ? {} : { timeoutMs }) });
|
|
54
62
|
return result.code === 0 ? result.stdout : undefined;
|
|
55
63
|
}
|
|
56
64
|
/** Real existence check. */
|
|
@@ -146,7 +154,7 @@ export async function probeRepo(exec, path, dirExists = fsDirExists) {
|
|
|
146
154
|
toplevel,
|
|
147
155
|
main: isMainWorktree(toplevel, gitDir),
|
|
148
156
|
currentBranch: await currentBranch(exec, path),
|
|
149
|
-
|
|
157
|
+
...await listBranches(exec, repoRoot),
|
|
150
158
|
// A stale registration (directory removed behind git's back) must not
|
|
151
159
|
// reach the UI as a real worktree — it would disable branch rows for a
|
|
152
160
|
// folder that no longer exists.
|
|
@@ -161,8 +169,11 @@ async function currentBranch(exec, cwd) {
|
|
|
161
169
|
}
|
|
162
170
|
/**
|
|
163
171
|
* Local branches (each carrying its ahead/behind vs the upstream when one
|
|
164
|
-
* exists) plus remote
|
|
165
|
-
* dropped)
|
|
172
|
+
* exists) plus remote branches across EVERY remote (`<remote>/HEAD` and bare
|
|
173
|
+
* leftover refs dropped), in two shapes: `branches` keeps the picker's
|
|
174
|
+
* remote-only dedupe (a remote branch with a local twin stays out — the twin
|
|
175
|
+
* is the actionable row), `remoteBranches` is the FULL remote view (twins
|
|
176
|
+
* included) the git panel's remote tree lists.
|
|
166
177
|
*
|
|
167
178
|
* The upstream track comes from `%(upstream:track)`, which renders
|
|
168
179
|
* `[ahead N, behind M]` (or a bare `[gone]`) directly after the refname —
|
|
@@ -192,6 +203,7 @@ async function listBranches(exec, repoRoot) {
|
|
|
192
203
|
entries.push(entry);
|
|
193
204
|
}
|
|
194
205
|
const remoteOut = await gitMaybe(exec, repoRoot, ['for-each-ref', 'refs/remotes', '--format=%(refname:short)']);
|
|
206
|
+
const remoteBranches = [];
|
|
195
207
|
if (remoteOut !== undefined) {
|
|
196
208
|
// Local names as a Set: the twin lookup runs once per remote row, and a
|
|
197
209
|
// linear scan over the assembled list would make the loop quadratic —
|
|
@@ -204,17 +216,20 @@ async function listBranches(exec, repoRoot) {
|
|
|
204
216
|
const localName = localBranchName(name);
|
|
205
217
|
// A bare ref directly under refs/remotes (shortname without `/`, like
|
|
206
218
|
// a leftover `refs/remotes/origin`) is not a remote branch — git
|
|
207
|
-
// branch -r hides it, the menu must not offer it either.
|
|
208
|
-
|
|
219
|
+
// branch -r hides it, the menu must not offer it either. `<remote>/HEAD`
|
|
220
|
+
// is a symbolic pointer, not a branch, and drops from both lists.
|
|
221
|
+
if (localName === name || localName === 'HEAD')
|
|
209
222
|
continue;
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
|
|
223
|
+
remoteBranches.push({ name, kind: 'remote' });
|
|
224
|
+
// A remote branch with a local twin stays out of the picker group:
|
|
225
|
+
// the twin is the actionable row, showing both would duplicate it in
|
|
226
|
+
// the menu. The full list above keeps it for the panel's tree.
|
|
227
|
+
if (localNames.has(localName))
|
|
213
228
|
continue;
|
|
214
229
|
entries.push({ name, kind: 'remote' });
|
|
215
230
|
}
|
|
216
231
|
}
|
|
217
|
-
return entries;
|
|
232
|
+
return { branches: entries, remoteBranches };
|
|
218
233
|
}
|
|
219
234
|
/** Parse `git worktree list --porcelain` into entries, main first. */
|
|
220
235
|
async function listWorktrees(exec, repoRoot) {
|
|
@@ -359,20 +374,22 @@ export async function addWorktreeCutout(exec, repoRoot, base, newBranch, targetP
|
|
|
359
374
|
await git(exec, repoRoot, ['worktree', 'add', targetPath, '-b', newBranch, base]);
|
|
360
375
|
}
|
|
361
376
|
/**
|
|
362
|
-
* In-place branch switch of
|
|
377
|
+
* In-place branch switch of one checkout. A remote display name relies
|
|
363
378
|
* on git's dwim: `git switch <local>` creates the tracking branch when
|
|
364
379
|
* exactly one remote has it.
|
|
365
380
|
* @param exec - executor seam.
|
|
366
|
-
* @param
|
|
381
|
+
* @param checkoutDir - the directory whose OWN checkout moves: the queried
|
|
382
|
+
* session's toplevel, so a linked worktree switches itself rather than the
|
|
383
|
+
* main checkout. `worktree prune` still runs here (it is repo-wide).
|
|
367
384
|
* @param branch - branch display name.
|
|
368
385
|
* @returns the local branch name now checked out.
|
|
369
386
|
*/
|
|
370
|
-
export async function switchBranch(exec,
|
|
371
|
-
const resolved = await resolveBranch(exec,
|
|
387
|
+
export async function switchBranch(exec, checkoutDir, branch) {
|
|
388
|
+
const resolved = await resolveBranch(exec, checkoutDir, branch);
|
|
372
389
|
if (resolved === undefined)
|
|
373
390
|
throw new GitError(['switch'], 1, `branch "${branch}" not found`);
|
|
374
391
|
try {
|
|
375
|
-
await git(exec,
|
|
392
|
+
await git(exec, checkoutDir, ['switch', resolved.local]);
|
|
376
393
|
}
|
|
377
394
|
catch (error) {
|
|
378
395
|
// A stale worktree registration claims the branch; prune (no-op on a
|
|
@@ -380,8 +397,8 @@ export async function switchBranch(exec, repoRoot, branch) {
|
|
|
380
397
|
const stale = error instanceof GitError && error.stderr.includes('already used by worktree');
|
|
381
398
|
if (!stale)
|
|
382
399
|
throw error;
|
|
383
|
-
await git(exec,
|
|
384
|
-
await git(exec,
|
|
400
|
+
await git(exec, checkoutDir, ['worktree', 'prune']);
|
|
401
|
+
await git(exec, checkoutDir, ['switch', resolved.local]);
|
|
385
402
|
}
|
|
386
403
|
return resolved.local;
|
|
387
404
|
}
|
|
@@ -457,7 +474,7 @@ export async function deleteBranch(exec, repoRoot, name) {
|
|
|
457
474
|
* @param repoRoot - main worktree directory (fetch is repository-wide).
|
|
458
475
|
*/
|
|
459
476
|
export async function fetchAll(exec, repoRoot) {
|
|
460
|
-
await git(exec, repoRoot, ['fetch', '--all', '--prune']);
|
|
477
|
+
await git(exec, repoRoot, ['fetch', '--all', '--prune'], NETWORK_TIMEOUT_MS);
|
|
461
478
|
}
|
|
462
479
|
/**
|
|
463
480
|
* Update the CURRENT checkout: fetch every remote, then fast-forward the
|
|
@@ -497,7 +514,7 @@ export async function updateBranch(exec, cwd) {
|
|
|
497
514
|
*/
|
|
498
515
|
export async function pushBranch(exec, cwd) {
|
|
499
516
|
const before = await gitMaybe(exec, cwd, ['rev-parse', '@{u}']);
|
|
500
|
-
await git(exec, cwd, ['push']);
|
|
517
|
+
await git(exec, cwd, ['push'], NETWORK_TIMEOUT_MS);
|
|
501
518
|
const after = await gitMaybe(exec, cwd, ['rev-parse', '@{u}']);
|
|
502
519
|
const branch = (await gitMaybe(exec, cwd, ['branch', '--show-current']))?.trim() ?? '';
|
|
503
520
|
return { branch, pushed: before !== undefined && after !== undefined && before.trim() !== after.trim() };
|
|
@@ -691,3 +708,289 @@ export async function appendWorktreeExclude(seams, repoRoot) {
|
|
|
691
708
|
return error instanceof Error ? error.message : String(error);
|
|
692
709
|
}
|
|
693
710
|
}
|
|
711
|
+
/** The XY pairs git reports as UNMERGED: until the path is re-staged, the
|
|
712
|
+
* index holds conflict stages and neither column is a plain status. Such a
|
|
713
|
+
* row surfaces as `unstaged: 'U'` (staging it resolves the conflict). */
|
|
714
|
+
const UNMERGED_XY = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);
|
|
715
|
+
/**
|
|
716
|
+
* Parse one `git status --porcelain=v1 -z` byte stream into change rows.
|
|
717
|
+
* NUL-separated so spaces and CJK in paths survive; a rename/copy entry is
|
|
718
|
+
* followed by its ORIGINAL path as the next NUL field. XY columns: staged
|
|
719
|
+
* (X) and unstaged (Y); `??` marks untracked; the unmerged pairs collapse
|
|
720
|
+
* to a conflict row (`unstaged: 'U'`).
|
|
721
|
+
*/
|
|
722
|
+
export function parseStatusPorcelainZ(out) {
|
|
723
|
+
const fields = out.split('\0');
|
|
724
|
+
const changes = [];
|
|
725
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
726
|
+
const entry = fields[i];
|
|
727
|
+
if (entry === undefined || entry === '')
|
|
728
|
+
continue;
|
|
729
|
+
const xy = entry.slice(0, 2);
|
|
730
|
+
// XY columns, then exactly one space separator, then the path.
|
|
731
|
+
const path = entry.slice(3);
|
|
732
|
+
if (path === '')
|
|
733
|
+
continue;
|
|
734
|
+
if (xy === '??') {
|
|
735
|
+
changes.push({ path, untracked: true });
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (UNMERGED_XY.has(xy)) {
|
|
739
|
+
changes.push({ path, unstaged: 'U' });
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const row = { path };
|
|
743
|
+
const x = xy[0] ?? ' ';
|
|
744
|
+
const y = xy[1] ?? ' ';
|
|
745
|
+
if (x !== ' ' && x !== '?') {
|
|
746
|
+
row.staged = x;
|
|
747
|
+
}
|
|
748
|
+
if (y !== ' ' && y !== '?') {
|
|
749
|
+
row.unstaged = y;
|
|
750
|
+
}
|
|
751
|
+
if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
|
|
752
|
+
// Rename/copy: the ORIGINAL path rides the next NUL field (new path first).
|
|
753
|
+
const oldPath = fields[i + 1];
|
|
754
|
+
i += 1;
|
|
755
|
+
if (oldPath !== undefined && oldPath !== '')
|
|
756
|
+
row.oldPath = oldPath;
|
|
757
|
+
}
|
|
758
|
+
if (row.staged !== undefined || row.unstaged !== undefined || row.untracked === true)
|
|
759
|
+
changes.push(row);
|
|
760
|
+
}
|
|
761
|
+
return changes;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Working-tree changes of the queried checkout: `git status --porcelain=v1 -z`
|
|
765
|
+
* with `core.quotepath=false` so CJK paths arrive unescaped.
|
|
766
|
+
* @param exec - executor seam.
|
|
767
|
+
* @param cwd - the checkout directory (worktree toplevel).
|
|
768
|
+
*/
|
|
769
|
+
export async function workingChanges(exec, cwd) {
|
|
770
|
+
const out = await git(exec, cwd, ['-c', 'core.quotepath=false', 'status', '--porcelain=v1', '-z']);
|
|
771
|
+
return { changes: parseStatusPorcelainZ(out) };
|
|
772
|
+
}
|
|
773
|
+
/** Guard for one (un)stage path: relative, no `..` escape, no drive root. */
|
|
774
|
+
export function isStageablePath(value) {
|
|
775
|
+
if (value === '' || value.length > 1024)
|
|
776
|
+
return false;
|
|
777
|
+
if (isAbsolute(value) || /^[a-zA-Z]:/.test(value))
|
|
778
|
+
return false;
|
|
779
|
+
// An untracked DIRECTORY arrives collapsed as `dir/` — one trailing slash
|
|
780
|
+
// is git's own shape and `git add` accepts it; only the trailing-slash
|
|
781
|
+
// form is trimmed, an interior empty segment still refuses.
|
|
782
|
+
const trimmed = value.endsWith('/') && value.length > 1 ? value.slice(0, -1) : value;
|
|
783
|
+
return trimmed.split('/').every(segment => segment !== '..' && segment !== '');
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Stage or unstage paths of the queried checkout. Staging is `git add`;
|
|
787
|
+
* unstaging is `git reset -q HEAD --` which also answers the two tolerant
|
|
788
|
+
* cases (an untracked path not in the index, an unborn HEAD) with a silent
|
|
789
|
+
* success — both were smoke-verified.
|
|
790
|
+
* @param exec - executor seam.
|
|
791
|
+
* @param cwd - the checkout directory.
|
|
792
|
+
* @param paths - relative paths (validated upstream via {@link isStageablePath}).
|
|
793
|
+
* @param stage - true to stage, false to unstage.
|
|
794
|
+
*/
|
|
795
|
+
export async function stagePaths(exec, cwd, paths, stage) {
|
|
796
|
+
if (paths.length === 0)
|
|
797
|
+
return;
|
|
798
|
+
if (stage) {
|
|
799
|
+
await git(exec, cwd, ['add', '--', ...paths]);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
await git(exec, cwd, ['reset', '-q', 'HEAD', '--', ...paths]);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Commit the index of the queried checkout (`git commit -m`). Hooks run as
|
|
806
|
+
* usual; an empty index is git's own refusal and surfaces as a GitError.
|
|
807
|
+
* @param exec - executor seam.
|
|
808
|
+
* @param cwd - the checkout directory.
|
|
809
|
+
* @param message - non-empty commit message (validated upstream).
|
|
810
|
+
*/
|
|
811
|
+
export async function commitIndex(exec, cwd, message) {
|
|
812
|
+
await git(exec, cwd, ['commit', '-m', message]);
|
|
813
|
+
const hash = (await git(exec, cwd, ['rev-parse', 'HEAD'])).trim();
|
|
814
|
+
const subject = message.split('\n')[0]?.trim() ?? '';
|
|
815
|
+
return { hash, subject };
|
|
816
|
+
}
|
|
817
|
+
/** The pretty format one log record is parsed from: %x1f field split, %x1e record split. */
|
|
818
|
+
const LOG_FORMAT = '%H%x1f%h%x1f%an%x1f%ae%x1f%ad%x1f%P%x1f%s%x1f%b%x1f%D%x1e';
|
|
819
|
+
/** Parse `git log` output produced with {@link LOG_FORMAT}. */
|
|
820
|
+
export function parseLogOutput(out) {
|
|
821
|
+
const commits = [];
|
|
822
|
+
for (const record of out.split('\x1e')) {
|
|
823
|
+
const text = record.startsWith('\n') ? record.slice(1) : record;
|
|
824
|
+
if (text.trim() === '')
|
|
825
|
+
continue;
|
|
826
|
+
const f = text.split('\x1f');
|
|
827
|
+
if (f.length < 9)
|
|
828
|
+
continue;
|
|
829
|
+
const hash = f[0] ?? '';
|
|
830
|
+
const short = f[1] ?? '';
|
|
831
|
+
const dateRaw = f[4] ?? '';
|
|
832
|
+
const parentsRaw = f[5] ?? '';
|
|
833
|
+
const refsRaw = f[8] ?? '';
|
|
834
|
+
if (hash === '' || short === '')
|
|
835
|
+
continue;
|
|
836
|
+
const date = Date.parse(dateRaw);
|
|
837
|
+
commits.push({
|
|
838
|
+
hash,
|
|
839
|
+
short,
|
|
840
|
+
authorName: f[2] ?? '',
|
|
841
|
+
authorEmail: f[3] ?? '',
|
|
842
|
+
date: Number.isFinite(date) ? date : 0,
|
|
843
|
+
parents: parentsRaw === '' ? [] : parentsRaw.split(' '),
|
|
844
|
+
subject: f[6] ?? '',
|
|
845
|
+
body: f[7] ?? '',
|
|
846
|
+
refs: refsRaw === '' ? [] : refsRaw.split(', ').map(ref => ref.trim()).filter(ref => ref !== ''),
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
return commits;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Paginated history of the queried checkout. `ref` is verified with
|
|
853
|
+
* `rev-parse --verify` first, so an arbitrary string can never reach the log
|
|
854
|
+
* arguments as anything but a resolvable rev. An unborn checkout (no commits
|
|
855
|
+
* yet) answers an empty page instead of an error.
|
|
856
|
+
* @param exec - executor seam.
|
|
857
|
+
* @param cwd - the checkout directory.
|
|
858
|
+
* @param limit - page size (1..200, validated upstream).
|
|
859
|
+
* @param skip - offset (>= 0, validated upstream).
|
|
860
|
+
* @param ref - optional starting rev; absent = the checkout's HEAD.
|
|
861
|
+
*/
|
|
862
|
+
export async function logHistory(exec, cwd, limit, skip, ref) {
|
|
863
|
+
let start;
|
|
864
|
+
if (ref !== undefined && ref !== '' && ref !== 'HEAD') {
|
|
865
|
+
const verified = await gitMaybe(exec, cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
866
|
+
if (verified === undefined || verified.trim() === '') {
|
|
867
|
+
throw new GitError(['log'], 1, `unknown revision "${ref}"`);
|
|
868
|
+
}
|
|
869
|
+
start = verified.trim();
|
|
870
|
+
}
|
|
871
|
+
try {
|
|
872
|
+
const out = await git(exec, cwd, [
|
|
873
|
+
'log', `--max-count=${String(limit)}`, `--skip=${String(skip)}`,
|
|
874
|
+
'--date=iso-strict', `--pretty=format:${LOG_FORMAT}`,
|
|
875
|
+
...(start === undefined ? [] : [start]),
|
|
876
|
+
]);
|
|
877
|
+
return parseLogOutput(out);
|
|
878
|
+
}
|
|
879
|
+
catch (error) {
|
|
880
|
+
// An unborn checkout refuses `git log` with exit 128; the stderr TEXT is
|
|
881
|
+
// locale-dependent, so confirm by asking HEAD directly instead of
|
|
882
|
+
// matching the message. An existing HEAD means a real failure — rethrow.
|
|
883
|
+
if (error instanceof GitError && error.code === 128
|
|
884
|
+
&& await gitMaybe(exec, cwd, ['rev-parse', '--verify', '--quiet', 'HEAD']) === undefined)
|
|
885
|
+
return [];
|
|
886
|
+
throw error;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Parse one `git diff --name-status -M` output block (tabs between fields).
|
|
891
|
+
* R/C rows carry `score\told\tnew`; plain rows `status\tpath`.
|
|
892
|
+
*/
|
|
893
|
+
export function parseNameStatus(out) {
|
|
894
|
+
const rows = [];
|
|
895
|
+
for (const line of out.split('\n')) {
|
|
896
|
+
if (line.trim() === '')
|
|
897
|
+
continue;
|
|
898
|
+
const parts = line.split('\t');
|
|
899
|
+
const head = parts[0] ?? '';
|
|
900
|
+
const letter = head[0] ?? '';
|
|
901
|
+
if (letter === 'R' || letter === 'C') {
|
|
902
|
+
const oldPath = parts[1] ?? '';
|
|
903
|
+
const path = parts[2] ?? '';
|
|
904
|
+
if (path === '')
|
|
905
|
+
continue;
|
|
906
|
+
rows.push(oldPath === '' ? { path, status: letter } : { path, oldPath, status: letter });
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
const path = parts[1] ?? '';
|
|
910
|
+
if (path === '')
|
|
911
|
+
continue;
|
|
912
|
+
if (letter === 'A' || letter === 'M' || letter === 'D' || letter === 'T') {
|
|
913
|
+
rows.push({ path, status: letter });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return rows;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Parse one `git diff --numstat -M` output block: `ins\tdel\tpath`, `-` for a
|
|
920
|
+
* binary file's counts. Only the COUNTS are consumed — paths (whose rename
|
|
921
|
+
* form `old => new` / `prefix{old => new}suffix` is ambiguous) come from the
|
|
922
|
+
* aligned name-status block instead.
|
|
923
|
+
*/
|
|
924
|
+
export function parseNumstatCounts(out) {
|
|
925
|
+
return out.split('\n').filter(line => line.trim() !== '').map(line => {
|
|
926
|
+
const parts = line.split('\t');
|
|
927
|
+
const ins = parts[0] ?? '-';
|
|
928
|
+
const del = parts[1] ?? '-';
|
|
929
|
+
const insertions = ins === '-' || !/^\d+$/.test(ins) ? undefined : Number(ins);
|
|
930
|
+
const deletions = del === '-' || !/^\d+$/.test(del) ? undefined : Number(del);
|
|
931
|
+
return { insertions, deletions };
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* The files one commit changed, of the queried checkout. The hash is
|
|
936
|
+
* verified with `rev-parse --verify` first; status comes from
|
|
937
|
+
* `--name-status -M`, the +/- line counts from the index-aligned
|
|
938
|
+
* `--numstat -M` block.
|
|
939
|
+
* @param exec - executor seam.
|
|
940
|
+
* @param cwd - the checkout directory.
|
|
941
|
+
* @param hash - the commit hash (validated upstream, verified here).
|
|
942
|
+
*/
|
|
943
|
+
export async function commitFiles(exec, cwd, hash) {
|
|
944
|
+
const verified = await gitMaybe(exec, cwd, ['rev-parse', '--verify', '--quiet', `${hash}^{commit}`]);
|
|
945
|
+
if (verified === undefined || verified.trim() === '') {
|
|
946
|
+
throw new GitError(['show'], 1, `unknown revision "${hash}"`);
|
|
947
|
+
}
|
|
948
|
+
const resolved = verified.trim();
|
|
949
|
+
const [statusOut, numstatOut] = await Promise.all([
|
|
950
|
+
git(exec, cwd, ['-c', 'core.quotepath=false', 'show', '--name-status', '-M', '--format=', resolved]),
|
|
951
|
+
git(exec, cwd, ['-c', 'core.quotepath=false', 'show', '--numstat', '-M', '--format=', resolved]),
|
|
952
|
+
]);
|
|
953
|
+
const rows = parseNameStatus(statusOut);
|
|
954
|
+
const counts = parseNumstatCounts(numstatOut);
|
|
955
|
+
return rows.map((row, index) => {
|
|
956
|
+
const count = counts[index];
|
|
957
|
+
const binary = count !== undefined && (count.insertions === undefined || count.deletions === undefined);
|
|
958
|
+
return {
|
|
959
|
+
...row,
|
|
960
|
+
...(count !== undefined && !binary && count.insertions !== undefined ? { insertions: count.insertions } : {}),
|
|
961
|
+
...(count !== undefined && !binary && count.deletions !== undefined ? { deletions: count.deletions } : {}),
|
|
962
|
+
...(binary ? { binary: true } : {}),
|
|
963
|
+
};
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
/** Cap for the diff text handed to the AI route (characters). */
|
|
967
|
+
export const AI_DIFF_CAP = 12_000;
|
|
968
|
+
/**
|
|
969
|
+
* The diff text the AI commit-message route sees: the staged diff when the
|
|
970
|
+
* index is non-empty, otherwise the unstaged diff plus untracked paths —
|
|
971
|
+
* an AI message for nothing staged should describe what WOULD be committed.
|
|
972
|
+
* Truncated to {@link AI_DIFF_CAP} characters; also collects the last
|
|
973
|
+
* subjects so the model can follow the repository's own style.
|
|
974
|
+
* @param exec - executor seam.
|
|
975
|
+
* @param cwd - the checkout directory.
|
|
976
|
+
*/
|
|
977
|
+
export async function collectAiDiff(exec, cwd) {
|
|
978
|
+
const stagedDiff = await gitMaybe(exec, cwd, ['-c', 'core.quotepath=false', 'diff', '--cached']);
|
|
979
|
+
let staged = true;
|
|
980
|
+
let diff = stagedDiff ?? '';
|
|
981
|
+
if (diff.trim() === '') {
|
|
982
|
+
staged = false;
|
|
983
|
+
const unstaged = await gitMaybe(exec, cwd, ['-c', 'core.quotepath=false', 'diff']);
|
|
984
|
+
const untracked = await workingChanges(exec, cwd);
|
|
985
|
+
const names = untracked.changes.filter(row => row.untracked).map(row => row.path);
|
|
986
|
+
diff = `${unstaged ?? ''}${names.length > 0 ? `\n${names.map(name => `Untracked file: ${name}`).join('\n')}` : ''}`;
|
|
987
|
+
}
|
|
988
|
+
if (diff.length > AI_DIFF_CAP)
|
|
989
|
+
diff = `${diff.slice(0, AI_DIFF_CAP)}\n…(diff truncated)`;
|
|
990
|
+
const recent = await gitMaybe(exec, cwd, ['log', '--max-count=10', '--pretty=format:%s']);
|
|
991
|
+
return {
|
|
992
|
+
diff,
|
|
993
|
+
recentSubjects: (recent ?? '').split('\n').map(line => line.trim()).filter(line => line !== ''),
|
|
994
|
+
staged,
|
|
995
|
+
};
|
|
996
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import z from '@deepseek-ai/schemastery';
|
|
10
10
|
import { childProcessExec } from './git.js';
|
|
11
|
-
import { handleCreateBranch, handleCreateWorktree, handleDeleteBranch, handleEnsureDirectory, handleFetch, handleGroupWorktrees, handleInspectWorktree, handlePathExists, handlePurgeDirectory, handleRemoveWorktree, handleRenameBranch, handleStatus, handleSwitch, handleUpdate, handlePush, handleWorktreesAll, } from './routes.js';
|
|
11
|
+
import { handleAiCommitMessage, handleChanges, handleCommit, handleCommitFiles, handleCreateBranch, handleCreateWorktree, handleDeleteBranch, handleEnsureDirectory, handleFetch, handleGroupWorktrees, handleInspectWorktree, handleLog, handlePathExists, handlePurgeDirectory, handleRemoveWorktree, handleRenameBranch, handleStage, handleStatus, handleSwitch, handleUpdate, handlePush, handleWorktreesAll, } from './routes.js';
|
|
12
12
|
import { validateRootDir } from './settings.js';
|
|
13
|
-
import { ROUTE_BRANCH, ROUTE_BRANCH_DELETE, ROUTE_BRANCH_RENAME, ROUTE_ENSURE_DIRECTORY, ROUTE_EXISTS, ROUTE_FETCH, ROUTE_GROUP, ROUTE_INSPECT, ROUTE_PUSH, ROUTE_PURGE, ROUTE_REMOVE, ROUTE_STATUS, ROUTE_SWITCH, ROUTE_UPDATE, ROUTE_WORKTREE, ROUTE_WORKTREES_ALL } from './wire.js';
|
|
13
|
+
import { ROUTE_AI_COMMIT_MESSAGE, ROUTE_BRANCH, ROUTE_BRANCH_DELETE, ROUTE_BRANCH_RENAME, ROUTE_CHANGES, ROUTE_COMMIT, ROUTE_COMMIT_FILES, ROUTE_ENSURE_DIRECTORY, ROUTE_EXISTS, ROUTE_FETCH, ROUTE_GROUP, ROUTE_INSPECT, ROUTE_LOG, ROUTE_PUSH, ROUTE_PURGE, ROUTE_REMOVE, ROUTE_STAGE, ROUTE_STATUS, ROUTE_SWITCH, ROUTE_UPDATE, ROUTE_WORKTREE, ROUTE_WORKTREES_ALL } from './wire.js';
|
|
14
14
|
export const name = 'dsh-git-branch';
|
|
15
15
|
export const inject = [];
|
|
16
16
|
/** Largest accepted request body (bytes) — these payloads are a few strings. */
|
|
@@ -195,5 +195,25 @@ export function apply(ctx, config = {}) {
|
|
|
195
195
|
postRoute(ROUTE_ENSURE_DIRECTORY, 'ensure-directory', handleEnsureDirectory);
|
|
196
196
|
postRoute(ROUTE_WORKTREES_ALL, 'worktrees-all', handleWorktreesAll);
|
|
197
197
|
postRoute(ROUTE_PURGE, 'purge', handlePurgeDirectory);
|
|
198
|
+
postRoute(ROUTE_CHANGES, 'changes', handleChanges);
|
|
199
|
+
postRoute(ROUTE_STAGE, 'stage', handleStage);
|
|
200
|
+
postRoute(ROUTE_COMMIT, 'commit', handleCommit);
|
|
201
|
+
postRoute(ROUTE_LOG, 'log', handleLog);
|
|
202
|
+
postRoute(ROUTE_COMMIT_FILES, 'commit-files', handleCommitFiles);
|
|
203
|
+
// The AI commit-message route exists only where the host tree provides
|
|
204
|
+
// the dsh-llm service: the injection resolves once the service is up and
|
|
205
|
+
// never fires without it, so an llm-less deployment answers 404 and the
|
|
206
|
+
// browser half hides its AI affordance.
|
|
207
|
+
ctx.inject(['llm'], (llmCtx) => {
|
|
208
|
+
const llm = llmCtx.llm;
|
|
209
|
+
webCtx.effect(() => webCtx.webServer.register({ kind: 'exact', path: ROUTE_AI_COMMIT_MESSAGE, handler: async (req, res) => {
|
|
210
|
+
try {
|
|
211
|
+
send(res, await handleAiCommitMessage(deps(), llm, await readJson(req)));
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
send(res, { status: 400, body: { error: error instanceof Error ? error.message : String(error) } });
|
|
215
|
+
}
|
|
216
|
+
} }), 'git-worktree: ai-commit-message route');
|
|
217
|
+
});
|
|
198
218
|
});
|
|
199
219
|
}
|