@imdeadpool/guardex 7.0.43 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +26 -0
  2. package/package.json +2 -1
  3. package/skills/gx-act/SKILL.md +82 -0
  4. package/src/agents/inspect.js +17 -4
  5. package/src/agents/launch.js +10 -1
  6. package/src/agents/status.js +9 -6
  7. package/src/budget/index.js +2 -1
  8. package/src/cli/args.js +52 -2
  9. package/src/cli/commands/agents.js +364 -0
  10. package/src/cli/commands/bootstrap.js +92 -0
  11. package/src/cli/commands/branch.js +127 -0
  12. package/src/cli/commands/claude.js +674 -0
  13. package/src/cli/commands/doctor.js +268 -0
  14. package/src/cli/commands/finish.js +26 -0
  15. package/src/cli/commands/mcp.js +122 -0
  16. package/src/cli/commands/misc.js +304 -0
  17. package/src/cli/commands/pr.js +439 -0
  18. package/src/cli/commands/prompt.js +92 -0
  19. package/src/cli/commands/release.js +305 -0
  20. package/src/cli/commands/report.js +244 -0
  21. package/src/cli/commands/review.js +32 -0
  22. package/src/cli/commands/setup.js +242 -0
  23. package/src/cli/commands/status.js +338 -0
  24. package/src/cli/commands/watch.js +234 -0
  25. package/src/cli/main.js +68 -3726
  26. package/src/cli/shared/repo-env.js +161 -0
  27. package/src/cli/shared/sandbox.js +417 -0
  28. package/src/cli/shared/scaffolding.js +535 -0
  29. package/src/cli/shared/toolchain-shims.js +420 -0
  30. package/src/context.js +229 -11
  31. package/src/core/runtime.js +6 -1
  32. package/src/doctor/index.js +42 -13
  33. package/src/finish/index.js +147 -5
  34. package/src/finish/preflight.js +177 -0
  35. package/src/finish/review-gate.js +182 -0
  36. package/src/git/index.js +446 -4
  37. package/src/hooks/index.js +0 -64
  38. package/src/mcp/collect.js +370 -0
  39. package/src/mcp/server.js +157 -0
  40. package/src/output/index.js +67 -1
  41. package/src/pr-review.js +23 -0
  42. package/src/pr.js +381 -0
  43. package/src/sandbox/index.js +13 -2
  44. package/src/scaffold/agent-worktree-prep.js +213 -0
  45. package/src/scaffold/index.js +108 -10
  46. package/src/speckit/index.js +226 -0
  47. package/src/terminal/index.js +1 -76
  48. package/src/terminal/tmux.js +0 -1
  49. package/src/toolchain/index.js +20 -0
  50. package/templates/AGENTS.monorepo-apps.md +26 -0
  51. package/templates/AGENTS.multiagent-safety.md +61 -347
  52. package/templates/AGENTS.multiagent-safety.min.md +11 -0
  53. package/templates/codex/skills/gx-act/SKILL.md +82 -0
  54. package/templates/githooks/pre-commit +22 -19
  55. package/templates/scripts/agent-branch-finish.sh +8 -30
  56. package/templates/scripts/agent-branch-merge.sh +4 -1
  57. package/templates/scripts/agent-branch-start.sh +88 -3
  58. package/templates/scripts/agent-preflight.sh +31 -5
  59. package/templates/scripts/agent-worktree-prune.sh +1 -1
  60. package/templates/scripts/codex-agent.sh +0 -91
  61. package/src/agents/detect.js +0 -160
  62. package/src/cockpit/keybindings.js +0 -224
  63. package/src/cockpit/layout.js +0 -224
package/src/git/index.js CHANGED
@@ -1,6 +1,8 @@
1
+ // @ts-check
1
2
  const fs = require('node:fs');
2
3
  const {
3
4
  path,
5
+ cachedSpawn,
4
6
  TOOL_NAME,
5
7
  GIT_PROTECTED_BRANCHES_KEY,
6
8
  GIT_BASE_BRANCH_KEY,
@@ -11,8 +13,109 @@ const {
11
13
  COMPOSE_HINT_FILES,
12
14
  LOCK_FILE_RELATIVE,
13
15
  } = require('../context');
14
- const { run } = require('../core/runtime');
15
16
 
17
+ /**
18
+ * Result of a synchronous child-process spawn.
19
+ *
20
+ * Mirrors the relevant subset of Node's `child_process.SpawnSyncReturns`
21
+ * without pulling in `@types/node`, so this typedef stays usable under
22
+ * `@ts-check` even when type definitions are not installed.
23
+ *
24
+ * @typedef {Object} SpawnResult
25
+ * @property {number|null} status Exit code, or `null` if the process was killed by a signal.
26
+ * @property {string} [stdout] Captured stdout (utf-8).
27
+ * @property {string} [stderr] Captured stderr (utf-8).
28
+ * @property {Error} [error] Spawn-level error (e.g., ENOENT, timeout).
29
+ */
30
+
31
+ /**
32
+ * Options accepted by {@link run}.
33
+ *
34
+ * @typedef {Object} RunOptions
35
+ * @property {string} [stdio] Stdio mode passed to spawnSync ('pipe' by default).
36
+ * @property {string} [cwd] Working directory for the child process.
37
+ * @property {Record<string, string|undefined>} [env] Extra env vars merged on top of `process.env`.
38
+ * @property {number} [timeout] Kill the child after this many milliseconds.
39
+ */
40
+
41
+ /**
42
+ * Outcome of an additive setup operation reported by setup-style helpers.
43
+ *
44
+ * @typedef {Object} SetupOperation
45
+ * @property {string} status Operation outcome (e.g., 'unchanged', 'updated', 'set', 'would-set', 'synced', 'failed').
46
+ * @property {string} file Human-readable identifier for the touched resource (often `git config <key>`).
47
+ * @property {string} [note] Optional explanation surfaced in setup output.
48
+ */
49
+
50
+ /**
51
+ * Porcelain status of the agent file-lock registry inside the working tree.
52
+ *
53
+ * @typedef {Object} LockRegistryStatus
54
+ * @property {boolean} dirty Lock file has uncommitted or untracked changes.
55
+ * @property {boolean} untracked Lock file is present but not tracked by git.
56
+ */
57
+
58
+ /**
59
+ * Ahead/behind counts between a branch and a base ref.
60
+ *
61
+ * @typedef {Object} AheadBehindCounts
62
+ * @property {number} ahead Commits on `branchRef` not on `baseRef`.
63
+ * @property {number} behind Commits on `baseRef` not on `branchRef`.
64
+ */
65
+
66
+ /**
67
+ * Outcome of attempting to switch the primary checkout onto a branch.
68
+ *
69
+ * @typedef {Object} EnsureRepoBranchResult
70
+ * @property {boolean} ok True when the working tree is on (or moved to) the requested branch.
71
+ * @property {boolean} changed True only when this call performed a checkout.
72
+ * @property {string} [stdout] Checkout stdout when the operation failed.
73
+ * @property {string} [stderr] Checkout stderr when the operation failed.
74
+ */
75
+
76
+ /**
77
+ * Worktree entry describing an `agent/*` branch checked out on disk.
78
+ *
79
+ * @typedef {Object} AgentWorktreeEntry
80
+ * @property {string} worktreePath Absolute filesystem path of the worktree.
81
+ * @property {string} branch Branch name (without the `refs/heads/` prefix).
82
+ */
83
+
84
+ /**
85
+ * Spawn `cmd` synchronously and capture its output as utf-8 text.
86
+ *
87
+ * Routes every call through the process-scoped probe cache in `context.js`
88
+ * so repeated read-only probes (e.g., `git rev-parse`, `git config --get`)
89
+ * answer from cache. `cachedSpawn` falls through to `cp.spawnSync` for any
90
+ * command not on its read-only allowlist (writes like `git commit`,
91
+ * `git push`, `git checkout`, `git worktree add/remove`), so this is a
92
+ * strict perf optimization preserving the `(cmd, args, opts) -> spawnSync
93
+ * result` signature.
94
+ *
95
+ * @param {string} cmd Executable to invoke.
96
+ * @param {ReadonlyArray<string>} args Argument vector.
97
+ * @param {RunOptions} [options] Spawn options (see {@link RunOptions}).
98
+ * @returns {SpawnResult} Result of the spawn.
99
+ */
100
+ function run(cmd, args, options = {}) {
101
+ return cachedSpawn(cmd, args, {
102
+ encoding: 'utf8',
103
+ stdio: options.stdio || 'pipe',
104
+ cwd: options.cwd,
105
+ env: options.env ? { ...process.env, ...options.env } : process.env,
106
+ timeout: options.timeout,
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Run `git -C <repoRoot> <args>` and throw unless `allowFailure` is set.
112
+ *
113
+ * @param {string} repoRoot Repository root the command should target.
114
+ * @param {ReadonlyArray<string>} args Argument vector passed after `-C <repoRoot>`.
115
+ * @param {{ allowFailure?: boolean }} [opts] When `allowFailure` is true, non-zero exits are returned to the caller instead of throwing.
116
+ * @returns {SpawnResult} The spawn result (always returned; only thrown on failure when `allowFailure` is false).
117
+ * @throws {Error} When git exits non-zero and `allowFailure` is not set.
118
+ */
16
119
  function gitRun(repoRoot, args, { allowFailure = false } = {}) {
17
120
  const result = run('git', ['-C', repoRoot, ...args]);
18
121
  if (!allowFailure && result.status !== 0) {
@@ -21,6 +124,13 @@ function gitRun(repoRoot, args, { allowFailure = false } = {}) {
21
124
  return result;
22
125
  }
23
126
 
127
+ /**
128
+ * Resolve the absolute git toplevel for `targetPath` (or `process.cwd()`).
129
+ *
130
+ * @param {string} [targetPath] Filesystem path inside a git repo. Defaults to the current working directory.
131
+ * @returns {string} Absolute path to the git toplevel.
132
+ * @throws {Error} When `targetPath` is not inside a git repository.
133
+ */
24
134
  function resolveRepoRoot(targetPath) {
25
135
  const resolvedTarget = path.resolve(targetPath || process.cwd());
26
136
  const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']);
@@ -33,6 +143,12 @@ function resolveRepoRoot(targetPath) {
33
143
  return result.stdout.trim();
34
144
  }
35
145
 
146
+ /**
147
+ * Test whether `targetPath` (or cwd) resolves inside a git repository.
148
+ *
149
+ * @param {string} [targetPath] Filesystem path to probe. Defaults to the current working directory.
150
+ * @returns {boolean} True if the path is inside a git repo.
151
+ */
36
152
  function isGitRepo(targetPath) {
37
153
  const resolvedTarget = path.resolve(targetPath || process.cwd());
38
154
  const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']);
@@ -61,6 +177,21 @@ function resolveGitCommonDir(repoPath) {
61
177
  return path.resolve(repoPath, raw);
62
178
  }
63
179
 
180
+ /**
181
+ * Walk `rootPath` and return every distinct git working tree found within it.
182
+ *
183
+ * Skips common heavy/disposable directories (`node_modules`, `dist`, etc.),
184
+ * excludes the root's own `.git`, and by default treats submodules
185
+ * (where `.git` is a file, not a directory) as part of the root unless
186
+ * `includeSubmodules` is set. Filters out worktree children that share the
187
+ * root's `git-common-dir` so additional worktrees of the same repo do not
188
+ * show up as nested repos.
189
+ *
190
+ * @param {string} rootPath Path to search.
191
+ * @param {{ maxDepth?: number, extraSkip?: ReadonlyArray<string>, includeSubmodules?: boolean, skipRelativeDirs?: ReadonlyArray<string> }} [opts] Walk tuning knobs.
192
+ * @returns {string[]} Sorted list of repo paths, with `rootPath` first.
193
+ * @throws {Error} When `rootPath` is not itself a git repository.
194
+ */
64
195
  function discoverNestedGitRepos(rootPath, opts = {}) {
65
196
  const maxDepth = Number.isFinite(opts.maxDepth)
66
197
  ? Math.max(1, opts.maxDepth)
@@ -124,6 +255,12 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
124
255
  return root ? [root, ...rest] : [];
125
256
  }
126
257
 
258
+ /**
259
+ * Split a whitespace/comma-delimited branch list into a deduped array.
260
+ *
261
+ * @param {unknown} rawValue Raw config value (string-coerced; non-strings yield `[]`).
262
+ * @returns {string[]} Branch names with empty entries removed.
263
+ */
127
264
  function parseBranchList(rawValue) {
128
265
  return String(rawValue || '')
129
266
  .split(/[\s,]+/)
@@ -131,8 +268,17 @@ function parseBranchList(rawValue) {
131
268
  .filter(Boolean);
132
269
  }
133
270
 
271
+ /**
272
+ * Return `items` with duplicates removed, preserving first-seen order.
273
+ *
274
+ * @template T
275
+ * @param {ReadonlyArray<T>} items Input list.
276
+ * @returns {T[]} Deduped list.
277
+ */
134
278
  function uniquePreserveOrder(items) {
279
+ /** @type {Set<T>} */
135
280
  const seen = new Set();
281
+ /** @type {T[]} */
136
282
  const result = [];
137
283
  for (const item of items) {
138
284
  if (seen.has(item)) continue;
@@ -142,6 +288,12 @@ function uniquePreserveOrder(items) {
142
288
  return result;
143
289
  }
144
290
 
291
+ /**
292
+ * Read the `GIT_PROTECTED_BRANCHES_KEY` config value as a deduped list.
293
+ *
294
+ * @param {string} repoRoot Repo to inspect.
295
+ * @returns {string[]|null} Parsed branches, or `null` when the key is unset / empty.
296
+ */
145
297
  function readConfiguredProtectedBranches(repoRoot) {
146
298
  const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
147
299
  if (result.status !== 0) {
@@ -154,6 +306,14 @@ function readConfiguredProtectedBranches(repoRoot) {
154
306
  return parsed;
155
307
  }
156
308
 
309
+ /**
310
+ * Enumerate local branches that look like user branches (not `agent/*`, not
311
+ * in `DEFAULT_PROTECTED_BRANCHES`). Falls back to the currently checked-out
312
+ * branch when no other user branches exist.
313
+ *
314
+ * @param {string} repoRoot Repo to inspect.
315
+ * @returns {string[]} User branch names.
316
+ */
157
317
  function listLocalUserBranches(repoRoot) {
158
318
  const result = gitRun(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { allowFailure: true });
159
319
  const branchNames = result.status === 0
@@ -191,6 +351,12 @@ function listLocalUserBranches(repoRoot) {
191
351
  return [branchName];
192
352
  }
193
353
 
354
+ /**
355
+ * Enumerate local branches under `refs/heads/agent/`.
356
+ *
357
+ * @param {string} repoRoot Repo to inspect.
358
+ * @returns {string[]} Agent branch names (deduped, in for-each-ref order).
359
+ */
194
360
  function listLocalAgentBranches(repoRoot) {
195
361
  const result = gitRun(
196
362
  repoRoot,
@@ -208,6 +374,12 @@ function listLocalAgentBranches(repoRoot) {
208
374
  );
209
375
  }
210
376
 
377
+ /**
378
+ * Build a `branch -> worktreePath` map from `git worktree list --porcelain`.
379
+ *
380
+ * @param {string} repoRoot Repo to inspect.
381
+ * @returns {Map<string, string>} Branch -> absolute worktree path; empty on failure.
382
+ */
211
383
  function mapWorktreePathsByBranch(repoRoot) {
212
384
  const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
213
385
  const map = new Map();
@@ -232,10 +404,24 @@ function mapWorktreePathsByBranch(repoRoot) {
232
404
  return map;
233
405
  }
234
406
 
407
+ /**
408
+ * Test whether `ref` resolves via `git show-ref --verify`.
409
+ *
410
+ * @param {string} repoRoot Repo to inspect.
411
+ * @param {string} ref Fully-qualified ref (e.g., `refs/heads/main`).
412
+ * @returns {boolean} True when the ref exists.
413
+ */
235
414
  function gitRefExists(repoRoot, ref) {
236
415
  return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
237
416
  }
238
417
 
418
+ /**
419
+ * Report whether `worktreePath` has working-tree changes the finish flow
420
+ * would consider significant, ignoring noise from the lock-registry file.
421
+ *
422
+ * @param {string} worktreePath Worktree to inspect.
423
+ * @returns {boolean} True when meaningful changes are present.
424
+ */
239
425
  function hasSignificantWorkingTreeChanges(worktreePath) {
240
426
  const result = run('git', [
241
427
  '-C',
@@ -265,6 +451,12 @@ function hasSignificantWorkingTreeChanges(worktreePath) {
265
451
  return false;
266
452
  }
267
453
 
454
+ /**
455
+ * Resolve the effective protected-branches list (config override or default).
456
+ *
457
+ * @param {string} repoRoot Repo to inspect.
458
+ * @returns {string[]} Protected branch names.
459
+ */
268
460
  function readProtectedBranches(repoRoot) {
269
461
  const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
270
462
  if (result.status !== 0) {
@@ -278,6 +470,14 @@ function readProtectedBranches(repoRoot) {
278
470
  return parsed;
279
471
  }
280
472
 
473
+ /**
474
+ * Add any local user branches that are missing from the protected-branches
475
+ * config. In dry-run mode the config is left untouched.
476
+ *
477
+ * @param {string} repoRoot Repo to inspect.
478
+ * @param {boolean} dryRun When true, report intended changes without writing.
479
+ * @returns {SetupOperation} Operation outcome (`unchanged`, `would-update`, or `updated`).
480
+ */
281
481
  function ensureSetupProtectedBranches(repoRoot, dryRun) {
282
482
  const localUserBranches = listLocalUserBranches(repoRoot);
283
483
  if (localUserBranches.length === 0) {
@@ -311,6 +511,14 @@ function ensureSetupProtectedBranches(repoRoot, dryRun) {
311
511
  };
312
512
  }
313
513
 
514
+ /**
515
+ * Persist the protected-branches list into git config, replacing the prior
516
+ * value. An empty array unsets the key entirely.
517
+ *
518
+ * @param {string} repoRoot Repo to update.
519
+ * @param {ReadonlyArray<string>} branches Branch names to record.
520
+ * @returns {void}
521
+ */
314
522
  function writeProtectedBranches(repoRoot, branches) {
315
523
  if (branches.length === 0) {
316
524
  gitRun(repoRoot, ['config', '--unset-all', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
@@ -332,6 +540,15 @@ const SUBMODULE_AUTO_SYNC_CONFIGS = [
332
540
  },
333
541
  ];
334
542
 
543
+ /**
544
+ * Wire git config so submodules auto-update on pull/fetch and snap working
545
+ * dirs to the parent index. No-op (returns `[]`) when the repo has no
546
+ * `.gitmodules` file. Existing config values are respected, not overwritten.
547
+ *
548
+ * @param {string} repoRoot Repo to configure.
549
+ * @param {boolean} dryRun When true, report intended changes without writing.
550
+ * @returns {SetupOperation[]} One operation per config key plus the recursive submodule sync result.
551
+ */
335
552
  function ensureSubmoduleAutoSync(repoRoot, dryRun) {
336
553
  const gitmodulesPath = path.join(repoRoot, '.gitmodules');
337
554
  if (!fs.existsSync(gitmodulesPath)) {
@@ -383,6 +600,13 @@ function ensureSubmoduleAutoSync(repoRoot, dryRun) {
383
600
  return operations;
384
601
  }
385
602
 
603
+ /**
604
+ * Read a single git config value as a trimmed string.
605
+ *
606
+ * @param {string} repoRoot Repo to inspect.
607
+ * @param {string} key Config key to read.
608
+ * @returns {string} Trimmed value, or '' when the key is unset.
609
+ */
386
610
  function readGitConfig(repoRoot, key) {
387
611
  const result = gitRun(repoRoot, ['config', '--get', key], { allowFailure: true });
388
612
  if (result.status !== 0) {
@@ -391,14 +615,65 @@ function readGitConfig(repoRoot, key) {
391
615
  return (result.stdout || '').trim();
392
616
  }
393
617
 
618
+ /**
619
+ * Detect the repository's real default branch when nothing is configured.
620
+ *
621
+ * Prefers the symbolic `origin/HEAD` (what the remote calls its default),
622
+ * then the first existing branch among `main`, `master`, `dev` (local or
623
+ * on origin), and only then falls back to the hardcoded `DEFAULT_BASE_BRANCH`.
624
+ * This stops the finish/PR flow from targeting a non-existent `dev` on repos
625
+ * whose base is actually `main`.
626
+ *
627
+ * @param {string} repoRoot Repo to inspect.
628
+ * @returns {string} Detected default base branch name.
629
+ */
630
+ function detectDefaultBaseBranch(repoRoot) {
631
+ const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], {
632
+ allowFailure: true,
633
+ });
634
+ if (symbolic.status === 0) {
635
+ const ref = String(symbolic.stdout || '').trim().replace(/^origin\//, '');
636
+ if (ref) {
637
+ return ref;
638
+ }
639
+ }
640
+
641
+ for (const candidate of ['main', 'master', 'dev']) {
642
+ if (
643
+ gitRefExists(repoRoot, `refs/heads/${candidate}`) ||
644
+ gitRefExists(repoRoot, `refs/remotes/origin/${candidate}`)
645
+ ) {
646
+ return candidate;
647
+ }
648
+ }
649
+
650
+ return DEFAULT_BASE_BRANCH;
651
+ }
652
+
653
+ /**
654
+ * Resolve the base branch to use (explicit CLI value wins; otherwise config
655
+ * key `GIT_BASE_BRANCH_KEY`; otherwise the repo's detected default branch).
656
+ *
657
+ * @param {string} repoRoot Repo to inspect.
658
+ * @param {string} [explicitBase] Value passed on the CLI, if any.
659
+ * @returns {string} Resolved base branch name.
660
+ */
394
661
  function resolveBaseBranch(repoRoot, explicitBase) {
395
662
  if (explicitBase) {
396
663
  return explicitBase;
397
664
  }
398
665
  const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
399
- return configured || DEFAULT_BASE_BRANCH;
666
+ return configured || detectDefaultBaseBranch(repoRoot);
400
667
  }
401
668
 
669
+ /**
670
+ * Resolve the sync strategy to use, validated against the allowed set.
671
+ *
672
+ * @param {string} repoRoot Repo to inspect.
673
+ * @param {string} [explicitStrategy] Value passed on the CLI, if any.
674
+ * @returns {'rebase'|'merge'} Resolved strategy (lower-cased).
675
+ * @throws {Error} When the resolved value is not `rebase` or `merge`.
676
+ */
402
677
  function resolveSyncStrategy(repoRoot, explicitStrategy) {
403
678
  const strategy = (explicitStrategy || readGitConfig(repoRoot, GIT_SYNC_STRATEGY_KEY) || DEFAULT_SYNC_STRATEGY)
404
679
  .trim()
@@ -409,6 +684,13 @@ function resolveSyncStrategy(repoRoot, explicitStrategy) {
409
684
  return strategy;
410
685
  }
411
686
 
687
+ /**
688
+ * Return the currently checked-out branch name.
689
+ *
690
+ * @param {string} repoRoot Repo to inspect.
691
+ * @returns {string} Current branch name.
692
+ * @throws {Error} When `git branch --show-current` fails or HEAD is detached.
693
+ */
412
694
  function currentBranchName(repoRoot) {
413
695
  const result = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true });
414
696
  if (result.status !== 0) {
@@ -421,10 +703,23 @@ function currentBranchName(repoRoot) {
421
703
  return branch;
422
704
  }
423
705
 
706
+ /**
707
+ * Test whether `HEAD` resolves to a commit (i.e., repo is not unborn).
708
+ *
709
+ * @param {string} repoRoot Repo to inspect.
710
+ * @returns {boolean} True when HEAD has a commit.
711
+ */
424
712
  function repoHasHeadCommit(repoRoot) {
425
713
  return gitRun(repoRoot, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0;
426
714
  }
427
715
 
716
+ /**
717
+ * Produce a human-readable description of HEAD: branch name, branch name
718
+ * annotated as unborn, a detached short SHA, or `(unknown)`.
719
+ *
720
+ * @param {string} repoRoot Repo to inspect.
721
+ * @returns {string} Display label for the current HEAD.
722
+ */
428
723
  function readBranchDisplayName(repoRoot) {
429
724
  const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { allowFailure: true });
430
725
  if (symbolic.status === 0) {
@@ -442,14 +737,35 @@ function readBranchDisplayName(repoRoot) {
442
737
  return '(unknown)';
443
738
  }
444
739
 
740
+ /**
741
+ * Test whether the repo has an `origin` remote configured.
742
+ *
743
+ * @param {string} repoRoot Repo to inspect.
744
+ * @returns {boolean} True when `git remote get-url origin` succeeds.
745
+ */
445
746
  function hasOriginRemote(repoRoot) {
446
747
  return gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
447
748
  }
448
749
 
750
+ /**
751
+ * Return the subset of `COMPOSE_HINT_FILES` that exist under `repoRoot`.
752
+ *
753
+ * @param {string} repoRoot Repo to inspect.
754
+ * @returns {string[]} Relative paths of compose-hint files present on disk.
755
+ */
449
756
  function detectComposeHintFiles(repoRoot) {
450
757
  return COMPOSE_HINT_FILES.filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
451
758
  }
452
759
 
760
+ /**
761
+ * Print onboarding hints for fresh repos (no HEAD commit, no origin, or
762
+ * docker-compose detected). Silent when none of those conditions apply.
763
+ *
764
+ * @param {string} repoRoot Repo to describe.
765
+ * @param {string} baseBranch Base branch to reference in suggested commands.
766
+ * @param {string} [repoLabel] Optional label injected into log lines (useful when iterating multiple repos).
767
+ * @returns {void}
768
+ */
453
769
  function printSetupRepoHints(repoRoot, baseBranch, repoLabel = '') {
454
770
  const branchDisplay = readBranchDisplayName(repoRoot);
455
771
  const hasHeadCommit = repoHasHeadCommit(repoRoot);
@@ -481,6 +797,14 @@ function printSetupRepoHints(repoRoot, baseBranch, repoLabel = '') {
481
797
  }
482
798
  }
483
799
 
800
+ /**
801
+ * Test whether the working tree at `repoRoot` has changes outside the
802
+ * lock-registry file.
803
+ *
804
+ * @param {string} repoRoot Repo to inspect.
805
+ * @returns {boolean} True when meaningful changes are present.
806
+ * @throws {Error} When `git status --porcelain` fails.
807
+ */
484
808
  function workingTreeIsDirty(repoRoot) {
485
809
  const result = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
486
810
  if (result.status !== 0) {
@@ -498,6 +822,15 @@ function workingTreeIsDirty(repoRoot) {
498
822
  return significant.length > 0;
499
823
  }
500
824
 
825
+ /**
826
+ * Ensure `repoRoot` is checked out on `branch`, performing a checkout if
827
+ * needed. Failures are reported via the returned object rather than thrown
828
+ * so callers can decide how to surface them.
829
+ *
830
+ * @param {string} repoRoot Repo to operate on.
831
+ * @param {string} branch Branch to check out.
832
+ * @returns {EnsureRepoBranchResult} Outcome of the operation.
833
+ */
501
834
  function ensureRepoBranch(repoRoot, branch) {
502
835
  const current = currentBranchName(repoRoot);
503
836
  if (current === branch) {
@@ -525,6 +858,14 @@ function ensureRepoBranch(repoRoot, branch) {
525
858
  return { ok: true, changed: true };
526
859
  }
527
860
 
861
+ /**
862
+ * Fetch `origin/<baseBranch>` and verify the remote ref now exists.
863
+ *
864
+ * @param {string} repoRoot Repo to operate on.
865
+ * @param {string} baseBranch Base branch name (without `origin/` prefix).
866
+ * @returns {void}
867
+ * @throws {Error} When the fetch fails or the remote base ref is missing.
868
+ */
528
869
  function ensureOriginBaseRef(repoRoot, baseBranch) {
529
870
  const fetch = gitRun(repoRoot, ['fetch', 'origin', baseBranch, '--quiet'], { allowFailure: true });
530
871
  if (fetch.status !== 0) {
@@ -540,6 +881,15 @@ function ensureOriginBaseRef(repoRoot, baseBranch) {
540
881
  }
541
882
  }
542
883
 
884
+ /**
885
+ * Compute the ahead/behind commit counts between two refs.
886
+ *
887
+ * @param {string} repoRoot Repo to inspect.
888
+ * @param {string} branchRef Branch ref (left side of `...`).
889
+ * @param {string} baseRef Base ref (right side of `...`).
890
+ * @returns {AheadBehindCounts} Commits ahead of and behind the base.
891
+ * @throws {Error} When `git rev-list` cannot compute the comparison.
892
+ */
543
893
  function aheadBehind(repoRoot, branchRef, baseRef) {
544
894
  const result = gitRun(repoRoot, ['rev-list', '--left-right', '--count', `${branchRef}...${baseRef}`], {
545
895
  allowFailure: true,
@@ -553,6 +903,12 @@ function aheadBehind(repoRoot, branchRef, baseRef) {
553
903
  return { ahead: Number.isFinite(ahead) ? ahead : 0, behind: Number.isFinite(behind) ? behind : 0 };
554
904
  }
555
905
 
906
+ /**
907
+ * Inspect `git status --porcelain` for just the lock-registry file.
908
+ *
909
+ * @param {string} repoRoot Repo to inspect.
910
+ * @returns {LockRegistryStatus} Whether the lock file is dirty and/or untracked.
911
+ */
556
912
  function lockRegistryStatus(repoRoot) {
557
913
  const result = gitRun(repoRoot, ['status', '--porcelain', '--', LOCK_FILE_RELATIVE], { allowFailure: true });
558
914
  if (result.status !== 0) {
@@ -566,6 +922,13 @@ function lockRegistryStatus(repoRoot) {
566
922
  return { dirty: true, untracked };
567
923
  }
568
924
 
925
+ /**
926
+ * Enumerate worktrees whose branch lives under `refs/heads/agent/`.
927
+ *
928
+ * @param {string} repoRoot Repo to inspect.
929
+ * @returns {AgentWorktreeEntry[]} One entry per agent worktree.
930
+ * @throws {Error} When `git worktree list` cannot run.
931
+ */
569
932
  function listAgentWorktrees(repoRoot) {
570
933
  const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
571
934
  if (result.status !== 0) {
@@ -607,12 +970,28 @@ function listAgentWorktrees(repoRoot) {
607
970
  return entries;
608
971
  }
609
972
 
973
+ /**
974
+ * Like {@link listLocalAgentBranches} but restricted to entries starting
975
+ * with `agent/`. Used as the candidate list for batch-finish flows.
976
+ *
977
+ * @param {string} repoRoot Repo to inspect.
978
+ * @returns {string[]} Agent branch names.
979
+ */
610
980
  function listLocalAgentBranchesForFinish(repoRoot) {
611
981
  return uniquePreserveOrder(
612
982
  listLocalAgentBranches(repoRoot).filter((line) => line.startsWith('agent/')),
613
983
  );
614
984
  }
615
985
 
986
+ /**
987
+ * Interpret a `git diff --quiet`-style command: 0 = no changes, 1 = changes,
988
+ * anything else is a real error.
989
+ *
990
+ * @param {string} worktreePath Worktree to invoke git in.
991
+ * @param {ReadonlyArray<string>} args Arguments after `-C <worktreePath>`.
992
+ * @returns {boolean} True when the diff reports changes.
993
+ * @throws {Error} When git exits with a status other than 0 or 1.
994
+ */
616
995
  function gitQuietChangeResult(worktreePath, args) {
617
996
  const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
618
997
  if (result.status === 0) {
@@ -628,6 +1007,14 @@ function gitQuietChangeResult(worktreePath, args) {
628
1007
  );
629
1008
  }
630
1009
 
1010
+ /**
1011
+ * Detect any pending local work in `worktreePath` (unstaged, staged, or
1012
+ * untracked), ignoring noise from the agent file-locks state file.
1013
+ *
1014
+ * @param {string} worktreePath Worktree to inspect.
1015
+ * @returns {boolean} True when local changes are present.
1016
+ * @throws {Error} When git commands invoked during the probe fail.
1017
+ */
631
1018
  function worktreeHasLocalChanges(worktreePath) {
632
1019
  const hasUnstaged = gitQuietChangeResult(worktreePath, [
633
1020
  'diff',
@@ -661,6 +1048,15 @@ function worktreeHasLocalChanges(worktreePath) {
661
1048
  return String(untracked.stdout || '').trim().length > 0;
662
1049
  }
663
1050
 
1051
+ /**
1052
+ * Run `git -C <worktreePath> <args>` and return stdout split into trimmed
1053
+ * non-empty lines.
1054
+ *
1055
+ * @param {string} worktreePath Worktree to invoke git in.
1056
+ * @param {ReadonlyArray<string>} args Arguments after `-C <worktreePath>`.
1057
+ * @returns {string[]} Trimmed stdout lines (empties removed).
1058
+ * @throws {Error} When git exits non-zero.
1059
+ */
664
1060
  function gitOutputLines(worktreePath, args) {
665
1061
  const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
666
1062
  if (result.status !== 0) {
@@ -676,6 +1072,13 @@ function gitOutputLines(worktreePath, args) {
676
1072
  .filter(Boolean);
677
1073
  }
678
1074
 
1075
+ /**
1076
+ * Test whether `refs/heads/<branch>` exists locally.
1077
+ *
1078
+ * @param {string} repoRoot Repo to inspect.
1079
+ * @param {string} branch Branch name (without `refs/heads/` prefix).
1080
+ * @returns {boolean} True when the local branch exists.
1081
+ */
679
1082
  function branchExists(repoRoot, branch) {
680
1083
  const result = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
681
1084
  allowFailure: true,
@@ -683,19 +1086,46 @@ function branchExists(repoRoot, branch) {
683
1086
  return result.status === 0;
684
1087
  }
685
1088
 
686
- function resolveFinishBaseBranch(repoRoot, _sourceBranch, explicitBase) {
1089
+ /**
1090
+ * Resolve the base branch for the finish flow: CLI override wins; otherwise
1091
+ * the per-branch `branch.<source>.guardexBase` recorded at branch-start;
1092
+ * otherwise the repo-wide configured base; otherwise the repo's detected
1093
+ * default branch.
1094
+ *
1095
+ * @param {string} repoRoot Repo to inspect.
1096
+ * @param {string} sourceBranch Source agent branch (used for per-branch base).
1097
+ * @param {string} [explicitBase] CLI override, if any.
1098
+ * @returns {string} Resolved base branch name.
1099
+ */
1100
+ function resolveFinishBaseBranch(repoRoot, sourceBranch, explicitBase) {
687
1101
  if (explicitBase) {
688
1102
  return explicitBase;
689
1103
  }
690
1104
 
1105
+ if (sourceBranch) {
1106
+ const perBranch = readGitConfig(repoRoot, `branch.${sourceBranch}.guardexBase`);
1107
+ if (perBranch) {
1108
+ return perBranch;
1109
+ }
1110
+ }
1111
+
691
1112
  const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
692
1113
  if (configured) {
693
1114
  return configured;
694
1115
  }
695
1116
 
696
- return DEFAULT_BASE_BRANCH;
1117
+ return detectDefaultBaseBranch(repoRoot);
697
1118
  }
698
1119
 
1120
+ /**
1121
+ * Test whether `branch` is an ancestor of `baseBranch` (i.e., already merged).
1122
+ *
1123
+ * @param {string} repoRoot Repo to inspect.
1124
+ * @param {string} branch Branch to check.
1125
+ * @param {string} baseBranch Base branch to compare against.
1126
+ * @returns {boolean} True when `branch` is an ancestor of `baseBranch`.
1127
+ * @throws {Error} When git returns an unexpected status (anything other than 0 or 1).
1128
+ */
699
1129
  function branchMergedIntoBase(repoRoot, branch, baseBranch) {
700
1130
  if (!branchExists(repoRoot, baseBranch)) {
701
1131
  return false;
@@ -712,6 +1142,17 @@ function branchMergedIntoBase(repoRoot, branch, baseBranch) {
712
1142
  throw new Error(`Unable to determine merge status for ${branch} -> ${baseBranch}`);
713
1143
  }
714
1144
 
1145
+ /**
1146
+ * Run a sync against `baseRef` using the requested strategy. On failure,
1147
+ * surface git output plus a hint for resolving an in-progress rebase/merge.
1148
+ *
1149
+ * @param {string} repoRoot Repo to operate on.
1150
+ * @param {'rebase'|'merge'} strategy Sync strategy.
1151
+ * @param {string} baseRef Ref to rebase onto / merge from.
1152
+ * @param {boolean} ffOnly Pass `--ff-only` to merge; rejected for rebase.
1153
+ * @returns {void}
1154
+ * @throws {Error} When the rebase/merge fails or when `ffOnly` is combined with `rebase`.
1155
+ */
715
1156
  function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
716
1157
  if (strategy === 'rebase') {
717
1158
  if (ffOnly) {
@@ -764,6 +1205,7 @@ module.exports = {
764
1205
  ensureSubmoduleAutoSync,
765
1206
  writeProtectedBranches,
766
1207
  readGitConfig,
1208
+ detectDefaultBaseBranch,
767
1209
  resolveBaseBranch,
768
1210
  resolveSyncStrategy,
769
1211
  currentBranchName,