dsh-git-branch-manage 1.0.2 → 1.0.3

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;
@@ -343,3 +357,135 @@ export declare const fsExcludeSeams: ExcludeSeams;
343
357
  * failure text otherwise.
344
358
  */
345
359
  export declare function appendWorktreeExclude(seams: ExcludeSeams, repoRoot: string): Promise<string | undefined>;
360
+ /** Working-tree facts of POST /changes. */
361
+ export interface WorkingChanges {
362
+ /** One row per changed path, in `git status --porcelain` order. */
363
+ changes: Array<{
364
+ path: string;
365
+ oldPath?: string;
366
+ staged?: 'A' | 'M' | 'D' | 'R' | 'C' | 'T';
367
+ unstaged?: 'A' | 'M' | 'D' | 'T' | 'U';
368
+ untracked?: boolean;
369
+ }>;
370
+ }
371
+ /**
372
+ * Parse one `git status --porcelain=v1 -z` byte stream into change rows.
373
+ * NUL-separated so spaces and CJK in paths survive; a rename/copy entry is
374
+ * followed by its ORIGINAL path as the next NUL field. XY columns: staged
375
+ * (X) and unstaged (Y); `??` marks untracked; the unmerged pairs collapse
376
+ * to a conflict row (`unstaged: 'U'`).
377
+ */
378
+ export declare function parseStatusPorcelainZ(out: string): WorkingChanges['changes'];
379
+ /**
380
+ * Working-tree changes of the queried checkout: `git status --porcelain=v1 -z`
381
+ * with `core.quotepath=false` so CJK paths arrive unescaped.
382
+ * @param exec - executor seam.
383
+ * @param cwd - the checkout directory (worktree toplevel).
384
+ */
385
+ export declare function workingChanges(exec: Exec, cwd: string): Promise<WorkingChanges>;
386
+ /** Guard for one (un)stage path: relative, no `..` escape, no drive root. */
387
+ export declare function isStageablePath(value: string): boolean;
388
+ /**
389
+ * Stage or unstage paths of the queried checkout. Staging is `git add`;
390
+ * unstaging is `git reset -q HEAD --` which also answers the two tolerant
391
+ * cases (an untracked path not in the index, an unborn HEAD) with a silent
392
+ * success — both were smoke-verified.
393
+ * @param exec - executor seam.
394
+ * @param cwd - the checkout directory.
395
+ * @param paths - relative paths (validated upstream via {@link isStageablePath}).
396
+ * @param stage - true to stage, false to unstage.
397
+ */
398
+ export declare function stagePaths(exec: Exec, cwd: string, paths: readonly string[], stage: boolean): Promise<void>;
399
+ /** Outcome of {@link commitIndex}. */
400
+ export interface CommitOutcome {
401
+ hash: string;
402
+ subject: string;
403
+ }
404
+ /**
405
+ * Commit the index of the queried checkout (`git commit -m`). Hooks run as
406
+ * usual; an empty index is git's own refusal and surfaces as a GitError.
407
+ * @param exec - executor seam.
408
+ * @param cwd - the checkout directory.
409
+ * @param message - non-empty commit message (validated upstream).
410
+ */
411
+ export declare function commitIndex(exec: Exec, cwd: string, message: string): Promise<CommitOutcome>;
412
+ /** Parse `git log` output produced with {@link LOG_FORMAT}. */
413
+ export declare function parseLogOutput(out: string): CommitEntryRaw[];
414
+ /** One parsed log row, before any client shaping. */
415
+ export interface CommitEntryRaw {
416
+ hash: string;
417
+ short: string;
418
+ authorName: string;
419
+ authorEmail: string;
420
+ date: number;
421
+ parents: string[];
422
+ subject: string;
423
+ body: string;
424
+ refs: string[];
425
+ }
426
+ /**
427
+ * Paginated history of the queried checkout. `ref` is verified with
428
+ * `rev-parse --verify` first, so an arbitrary string can never reach the log
429
+ * arguments as anything but a resolvable rev. An unborn checkout (no commits
430
+ * yet) answers an empty page instead of an error.
431
+ * @param exec - executor seam.
432
+ * @param cwd - the checkout directory.
433
+ * @param limit - page size (1..200, validated upstream).
434
+ * @param skip - offset (>= 0, validated upstream).
435
+ * @param ref - optional starting rev; absent = the checkout's HEAD.
436
+ */
437
+ export declare function logHistory(exec: Exec, cwd: string, limit: number, skip: number, ref?: string): Promise<CommitEntryRaw[]>;
438
+ /** One file row of {@link commitFiles}. */
439
+ export interface CommitFileRow {
440
+ path: string;
441
+ oldPath?: string;
442
+ status: 'A' | 'M' | 'D' | 'R' | 'C' | 'T';
443
+ insertions?: number;
444
+ deletions?: number;
445
+ binary?: boolean;
446
+ }
447
+ /**
448
+ * Parse one `git diff --name-status -M` output block (tabs between fields).
449
+ * R/C rows carry `score\told\tnew`; plain rows `status\tpath`.
450
+ */
451
+ export declare function parseNameStatus(out: string): Array<{
452
+ path: string;
453
+ oldPath?: string;
454
+ status: CommitFileRow['status'];
455
+ }>;
456
+ /**
457
+ * Parse one `git diff --numstat -M` output block: `ins\tdel\tpath`, `-` for a
458
+ * binary file's counts. Only the COUNTS are consumed — paths (whose rename
459
+ * form `old => new` / `prefix{old => new}suffix` is ambiguous) come from the
460
+ * aligned name-status block instead.
461
+ */
462
+ export declare function parseNumstatCounts(out: string): Array<{
463
+ insertions: number | undefined;
464
+ deletions: number | undefined;
465
+ }>;
466
+ /**
467
+ * The files one commit changed, of the queried checkout. The hash is
468
+ * verified with `rev-parse --verify` first; status comes from
469
+ * `--name-status -M`, the +/- line counts from the index-aligned
470
+ * `--numstat -M` block.
471
+ * @param exec - executor seam.
472
+ * @param cwd - the checkout directory.
473
+ * @param hash - the commit hash (validated upstream, verified here).
474
+ */
475
+ export declare function commitFiles(exec: Exec, cwd: string, hash: string): Promise<CommitFileRow[]>;
476
+ /** Cap for the diff text handed to the AI route (characters). */
477
+ export declare const AI_DIFF_CAP = 12000;
478
+ /**
479
+ * The diff text the AI commit-message route sees: the staged diff when the
480
+ * index is non-empty, otherwise the unstaged diff plus untracked paths —
481
+ * an AI message for nothing staged should describe what WOULD be committed.
482
+ * Truncated to {@link AI_DIFF_CAP} characters; also collects the last
483
+ * subjects so the model can follow the repository's own style.
484
+ * @param exec - executor seam.
485
+ * @param cwd - the checkout directory.
486
+ */
487
+ export declare function collectAiDiff(exec: Exec, cwd: string): Promise<{
488
+ diff: string;
489
+ recentSubjects: string[];
490
+ staged: boolean;
491
+ }>;
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) {
@@ -457,7 +472,7 @@ export async function deleteBranch(exec, repoRoot, name) {
457
472
  * @param repoRoot - main worktree directory (fetch is repository-wide).
458
473
  */
459
474
  export async function fetchAll(exec, repoRoot) {
460
- await git(exec, repoRoot, ['fetch', '--all', '--prune']);
475
+ await git(exec, repoRoot, ['fetch', '--all', '--prune'], NETWORK_TIMEOUT_MS);
461
476
  }
462
477
  /**
463
478
  * Update the CURRENT checkout: fetch every remote, then fast-forward the
@@ -497,7 +512,7 @@ export async function updateBranch(exec, cwd) {
497
512
  */
498
513
  export async function pushBranch(exec, cwd) {
499
514
  const before = await gitMaybe(exec, cwd, ['rev-parse', '@{u}']);
500
- await git(exec, cwd, ['push']);
515
+ await git(exec, cwd, ['push'], NETWORK_TIMEOUT_MS);
501
516
  const after = await gitMaybe(exec, cwd, ['rev-parse', '@{u}']);
502
517
  const branch = (await gitMaybe(exec, cwd, ['branch', '--show-current']))?.trim() ?? '';
503
518
  return { branch, pushed: before !== undefined && after !== undefined && before.trim() !== after.trim() };
@@ -691,3 +706,289 @@ export async function appendWorktreeExclude(seams, repoRoot) {
691
706
  return error instanceof Error ? error.message : String(error);
692
707
  }
693
708
  }
709
+ /** The XY pairs git reports as UNMERGED: until the path is re-staged, the
710
+ * index holds conflict stages and neither column is a plain status. Such a
711
+ * row surfaces as `unstaged: 'U'` (staging it resolves the conflict). */
712
+ const UNMERGED_XY = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);
713
+ /**
714
+ * Parse one `git status --porcelain=v1 -z` byte stream into change rows.
715
+ * NUL-separated so spaces and CJK in paths survive; a rename/copy entry is
716
+ * followed by its ORIGINAL path as the next NUL field. XY columns: staged
717
+ * (X) and unstaged (Y); `??` marks untracked; the unmerged pairs collapse
718
+ * to a conflict row (`unstaged: 'U'`).
719
+ */
720
+ export function parseStatusPorcelainZ(out) {
721
+ const fields = out.split('\0');
722
+ const changes = [];
723
+ for (let i = 0; i < fields.length; i += 1) {
724
+ const entry = fields[i];
725
+ if (entry === undefined || entry === '')
726
+ continue;
727
+ const xy = entry.slice(0, 2);
728
+ // XY columns, then exactly one space separator, then the path.
729
+ const path = entry.slice(3);
730
+ if (path === '')
731
+ continue;
732
+ if (xy === '??') {
733
+ changes.push({ path, untracked: true });
734
+ continue;
735
+ }
736
+ if (UNMERGED_XY.has(xy)) {
737
+ changes.push({ path, unstaged: 'U' });
738
+ continue;
739
+ }
740
+ const row = { path };
741
+ const x = xy[0] ?? ' ';
742
+ const y = xy[1] ?? ' ';
743
+ if (x !== ' ' && x !== '?') {
744
+ row.staged = x;
745
+ }
746
+ if (y !== ' ' && y !== '?') {
747
+ row.unstaged = y;
748
+ }
749
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
750
+ // Rename/copy: the ORIGINAL path rides the next NUL field (new path first).
751
+ const oldPath = fields[i + 1];
752
+ i += 1;
753
+ if (oldPath !== undefined && oldPath !== '')
754
+ row.oldPath = oldPath;
755
+ }
756
+ if (row.staged !== undefined || row.unstaged !== undefined || row.untracked === true)
757
+ changes.push(row);
758
+ }
759
+ return changes;
760
+ }
761
+ /**
762
+ * Working-tree changes of the queried checkout: `git status --porcelain=v1 -z`
763
+ * with `core.quotepath=false` so CJK paths arrive unescaped.
764
+ * @param exec - executor seam.
765
+ * @param cwd - the checkout directory (worktree toplevel).
766
+ */
767
+ export async function workingChanges(exec, cwd) {
768
+ const out = await git(exec, cwd, ['-c', 'core.quotepath=false', 'status', '--porcelain=v1', '-z']);
769
+ return { changes: parseStatusPorcelainZ(out) };
770
+ }
771
+ /** Guard for one (un)stage path: relative, no `..` escape, no drive root. */
772
+ export function isStageablePath(value) {
773
+ if (value === '' || value.length > 1024)
774
+ return false;
775
+ if (isAbsolute(value) || /^[a-zA-Z]:/.test(value))
776
+ return false;
777
+ // An untracked DIRECTORY arrives collapsed as `dir/` — one trailing slash
778
+ // is git's own shape and `git add` accepts it; only the trailing-slash
779
+ // form is trimmed, an interior empty segment still refuses.
780
+ const trimmed = value.endsWith('/') && value.length > 1 ? value.slice(0, -1) : value;
781
+ return trimmed.split('/').every(segment => segment !== '..' && segment !== '');
782
+ }
783
+ /**
784
+ * Stage or unstage paths of the queried checkout. Staging is `git add`;
785
+ * unstaging is `git reset -q HEAD --` which also answers the two tolerant
786
+ * cases (an untracked path not in the index, an unborn HEAD) with a silent
787
+ * success — both were smoke-verified.
788
+ * @param exec - executor seam.
789
+ * @param cwd - the checkout directory.
790
+ * @param paths - relative paths (validated upstream via {@link isStageablePath}).
791
+ * @param stage - true to stage, false to unstage.
792
+ */
793
+ export async function stagePaths(exec, cwd, paths, stage) {
794
+ if (paths.length === 0)
795
+ return;
796
+ if (stage) {
797
+ await git(exec, cwd, ['add', '--', ...paths]);
798
+ return;
799
+ }
800
+ await git(exec, cwd, ['reset', '-q', 'HEAD', '--', ...paths]);
801
+ }
802
+ /**
803
+ * Commit the index of the queried checkout (`git commit -m`). Hooks run as
804
+ * usual; an empty index is git's own refusal and surfaces as a GitError.
805
+ * @param exec - executor seam.
806
+ * @param cwd - the checkout directory.
807
+ * @param message - non-empty commit message (validated upstream).
808
+ */
809
+ export async function commitIndex(exec, cwd, message) {
810
+ await git(exec, cwd, ['commit', '-m', message]);
811
+ const hash = (await git(exec, cwd, ['rev-parse', 'HEAD'])).trim();
812
+ const subject = message.split('\n')[0]?.trim() ?? '';
813
+ return { hash, subject };
814
+ }
815
+ /** The pretty format one log record is parsed from: %x1f field split, %x1e record split. */
816
+ const LOG_FORMAT = '%H%x1f%h%x1f%an%x1f%ae%x1f%ad%x1f%P%x1f%s%x1f%b%x1f%D%x1e';
817
+ /** Parse `git log` output produced with {@link LOG_FORMAT}. */
818
+ export function parseLogOutput(out) {
819
+ const commits = [];
820
+ for (const record of out.split('\x1e')) {
821
+ const text = record.startsWith('\n') ? record.slice(1) : record;
822
+ if (text.trim() === '')
823
+ continue;
824
+ const f = text.split('\x1f');
825
+ if (f.length < 9)
826
+ continue;
827
+ const hash = f[0] ?? '';
828
+ const short = f[1] ?? '';
829
+ const dateRaw = f[4] ?? '';
830
+ const parentsRaw = f[5] ?? '';
831
+ const refsRaw = f[8] ?? '';
832
+ if (hash === '' || short === '')
833
+ continue;
834
+ const date = Date.parse(dateRaw);
835
+ commits.push({
836
+ hash,
837
+ short,
838
+ authorName: f[2] ?? '',
839
+ authorEmail: f[3] ?? '',
840
+ date: Number.isFinite(date) ? date : 0,
841
+ parents: parentsRaw === '' ? [] : parentsRaw.split(' '),
842
+ subject: f[6] ?? '',
843
+ body: f[7] ?? '',
844
+ refs: refsRaw === '' ? [] : refsRaw.split(', ').map(ref => ref.trim()).filter(ref => ref !== ''),
845
+ });
846
+ }
847
+ return commits;
848
+ }
849
+ /**
850
+ * Paginated history of the queried checkout. `ref` is verified with
851
+ * `rev-parse --verify` first, so an arbitrary string can never reach the log
852
+ * arguments as anything but a resolvable rev. An unborn checkout (no commits
853
+ * yet) answers an empty page instead of an error.
854
+ * @param exec - executor seam.
855
+ * @param cwd - the checkout directory.
856
+ * @param limit - page size (1..200, validated upstream).
857
+ * @param skip - offset (>= 0, validated upstream).
858
+ * @param ref - optional starting rev; absent = the checkout's HEAD.
859
+ */
860
+ export async function logHistory(exec, cwd, limit, skip, ref) {
861
+ let start;
862
+ if (ref !== undefined && ref !== '' && ref !== 'HEAD') {
863
+ const verified = await gitMaybe(exec, cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
864
+ if (verified === undefined || verified.trim() === '') {
865
+ throw new GitError(['log'], 1, `unknown revision "${ref}"`);
866
+ }
867
+ start = verified.trim();
868
+ }
869
+ try {
870
+ const out = await git(exec, cwd, [
871
+ 'log', `--max-count=${String(limit)}`, `--skip=${String(skip)}`,
872
+ '--date=iso-strict', `--pretty=format:${LOG_FORMAT}`,
873
+ ...(start === undefined ? [] : [start]),
874
+ ]);
875
+ return parseLogOutput(out);
876
+ }
877
+ catch (error) {
878
+ // An unborn checkout refuses `git log` with exit 128; the stderr TEXT is
879
+ // locale-dependent, so confirm by asking HEAD directly instead of
880
+ // matching the message. An existing HEAD means a real failure — rethrow.
881
+ if (error instanceof GitError && error.code === 128
882
+ && await gitMaybe(exec, cwd, ['rev-parse', '--verify', '--quiet', 'HEAD']) === undefined)
883
+ return [];
884
+ throw error;
885
+ }
886
+ }
887
+ /**
888
+ * Parse one `git diff --name-status -M` output block (tabs between fields).
889
+ * R/C rows carry `score\told\tnew`; plain rows `status\tpath`.
890
+ */
891
+ export function parseNameStatus(out) {
892
+ const rows = [];
893
+ for (const line of out.split('\n')) {
894
+ if (line.trim() === '')
895
+ continue;
896
+ const parts = line.split('\t');
897
+ const head = parts[0] ?? '';
898
+ const letter = head[0] ?? '';
899
+ if (letter === 'R' || letter === 'C') {
900
+ const oldPath = parts[1] ?? '';
901
+ const path = parts[2] ?? '';
902
+ if (path === '')
903
+ continue;
904
+ rows.push(oldPath === '' ? { path, status: letter } : { path, oldPath, status: letter });
905
+ continue;
906
+ }
907
+ const path = parts[1] ?? '';
908
+ if (path === '')
909
+ continue;
910
+ if (letter === 'A' || letter === 'M' || letter === 'D' || letter === 'T') {
911
+ rows.push({ path, status: letter });
912
+ }
913
+ }
914
+ return rows;
915
+ }
916
+ /**
917
+ * Parse one `git diff --numstat -M` output block: `ins\tdel\tpath`, `-` for a
918
+ * binary file's counts. Only the COUNTS are consumed — paths (whose rename
919
+ * form `old => new` / `prefix{old => new}suffix` is ambiguous) come from the
920
+ * aligned name-status block instead.
921
+ */
922
+ export function parseNumstatCounts(out) {
923
+ return out.split('\n').filter(line => line.trim() !== '').map(line => {
924
+ const parts = line.split('\t');
925
+ const ins = parts[0] ?? '-';
926
+ const del = parts[1] ?? '-';
927
+ const insertions = ins === '-' || !/^\d+$/.test(ins) ? undefined : Number(ins);
928
+ const deletions = del === '-' || !/^\d+$/.test(del) ? undefined : Number(del);
929
+ return { insertions, deletions };
930
+ });
931
+ }
932
+ /**
933
+ * The files one commit changed, of the queried checkout. The hash is
934
+ * verified with `rev-parse --verify` first; status comes from
935
+ * `--name-status -M`, the +/- line counts from the index-aligned
936
+ * `--numstat -M` block.
937
+ * @param exec - executor seam.
938
+ * @param cwd - the checkout directory.
939
+ * @param hash - the commit hash (validated upstream, verified here).
940
+ */
941
+ export async function commitFiles(exec, cwd, hash) {
942
+ const verified = await gitMaybe(exec, cwd, ['rev-parse', '--verify', '--quiet', `${hash}^{commit}`]);
943
+ if (verified === undefined || verified.trim() === '') {
944
+ throw new GitError(['show'], 1, `unknown revision "${hash}"`);
945
+ }
946
+ const resolved = verified.trim();
947
+ const [statusOut, numstatOut] = await Promise.all([
948
+ git(exec, cwd, ['-c', 'core.quotepath=false', 'show', '--name-status', '-M', '--format=', resolved]),
949
+ git(exec, cwd, ['-c', 'core.quotepath=false', 'show', '--numstat', '-M', '--format=', resolved]),
950
+ ]);
951
+ const rows = parseNameStatus(statusOut);
952
+ const counts = parseNumstatCounts(numstatOut);
953
+ return rows.map((row, index) => {
954
+ const count = counts[index];
955
+ const binary = count !== undefined && (count.insertions === undefined || count.deletions === undefined);
956
+ return {
957
+ ...row,
958
+ ...(count !== undefined && !binary && count.insertions !== undefined ? { insertions: count.insertions } : {}),
959
+ ...(count !== undefined && !binary && count.deletions !== undefined ? { deletions: count.deletions } : {}),
960
+ ...(binary ? { binary: true } : {}),
961
+ };
962
+ });
963
+ }
964
+ /** Cap for the diff text handed to the AI route (characters). */
965
+ export const AI_DIFF_CAP = 12_000;
966
+ /**
967
+ * The diff text the AI commit-message route sees: the staged diff when the
968
+ * index is non-empty, otherwise the unstaged diff plus untracked paths —
969
+ * an AI message for nothing staged should describe what WOULD be committed.
970
+ * Truncated to {@link AI_DIFF_CAP} characters; also collects the last
971
+ * subjects so the model can follow the repository's own style.
972
+ * @param exec - executor seam.
973
+ * @param cwd - the checkout directory.
974
+ */
975
+ export async function collectAiDiff(exec, cwd) {
976
+ const stagedDiff = await gitMaybe(exec, cwd, ['-c', 'core.quotepath=false', 'diff', '--cached']);
977
+ let staged = true;
978
+ let diff = stagedDiff ?? '';
979
+ if (diff.trim() === '') {
980
+ staged = false;
981
+ const unstaged = await gitMaybe(exec, cwd, ['-c', 'core.quotepath=false', 'diff']);
982
+ const untracked = await workingChanges(exec, cwd);
983
+ const names = untracked.changes.filter(row => row.untracked).map(row => row.path);
984
+ diff = `${unstaged ?? ''}${names.length > 0 ? `\n${names.map(name => `Untracked file: ${name}`).join('\n')}` : ''}`;
985
+ }
986
+ if (diff.length > AI_DIFF_CAP)
987
+ diff = `${diff.slice(0, AI_DIFF_CAP)}\n…(diff truncated)`;
988
+ const recent = await gitMaybe(exec, cwd, ['log', '--max-count=10', '--pretty=format:%s']);
989
+ return {
990
+ diff,
991
+ recentSubjects: (recent ?? '').split('\n').map(line => line.trim()).filter(line => line !== ''),
992
+ staged,
993
+ };
994
+ }
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
  }