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/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 the main checkout. A remote display name relies
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 repoRoot - main worktree directory.
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, repoRoot: string, branch: string): Promise<string>;
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: 20_000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
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
- branches: await listBranches(exec, repoRoot),
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-only branches across EVERY remote (`<remote>/HEAD`
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
- if (localName === name)
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
- // A remote branch with a local twin stays hidden: the twin is the
211
- // actionable row, showing both would duplicate it in the menu.
212
- if (localName === 'HEAD' || localNames.has(localName))
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 the main checkout. A remote display name relies
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 repoRoot - main worktree directory.
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, repoRoot, branch) {
371
- const resolved = await resolveBranch(exec, repoRoot, branch);
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, repoRoot, ['switch', resolved.local]);
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, repoRoot, ['worktree', 'prune']);
384
- await git(exec, repoRoot, ['switch', resolved.local]);
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
  }