@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.
- package/README.md +26 -0
- package/package.json +2 -1
- package/skills/gx-act/SKILL.md +82 -0
- package/src/agents/inspect.js +17 -4
- package/src/agents/launch.js +10 -1
- package/src/agents/status.js +9 -6
- package/src/budget/index.js +2 -1
- package/src/cli/args.js +52 -2
- package/src/cli/commands/agents.js +364 -0
- package/src/cli/commands/bootstrap.js +92 -0
- package/src/cli/commands/branch.js +127 -0
- package/src/cli/commands/claude.js +674 -0
- package/src/cli/commands/doctor.js +268 -0
- package/src/cli/commands/finish.js +26 -0
- package/src/cli/commands/mcp.js +122 -0
- package/src/cli/commands/misc.js +304 -0
- package/src/cli/commands/pr.js +439 -0
- package/src/cli/commands/prompt.js +92 -0
- package/src/cli/commands/release.js +305 -0
- package/src/cli/commands/report.js +244 -0
- package/src/cli/commands/review.js +32 -0
- package/src/cli/commands/setup.js +242 -0
- package/src/cli/commands/status.js +338 -0
- package/src/cli/commands/watch.js +234 -0
- package/src/cli/main.js +68 -3726
- package/src/cli/shared/repo-env.js +161 -0
- package/src/cli/shared/sandbox.js +417 -0
- package/src/cli/shared/scaffolding.js +535 -0
- package/src/cli/shared/toolchain-shims.js +420 -0
- package/src/context.js +229 -11
- package/src/core/runtime.js +6 -1
- package/src/doctor/index.js +42 -13
- package/src/finish/index.js +147 -5
- package/src/finish/preflight.js +177 -0
- package/src/finish/review-gate.js +182 -0
- package/src/git/index.js +446 -4
- package/src/hooks/index.js +0 -64
- package/src/mcp/collect.js +370 -0
- package/src/mcp/server.js +157 -0
- package/src/output/index.js +67 -1
- package/src/pr-review.js +23 -0
- package/src/pr.js +381 -0
- package/src/sandbox/index.js +13 -2
- package/src/scaffold/agent-worktree-prep.js +213 -0
- package/src/scaffold/index.js +108 -10
- package/src/speckit/index.js +226 -0
- package/src/terminal/index.js +1 -76
- package/src/terminal/tmux.js +0 -1
- package/src/toolchain/index.js +20 -0
- package/templates/AGENTS.monorepo-apps.md +26 -0
- package/templates/AGENTS.multiagent-safety.md +61 -347
- package/templates/AGENTS.multiagent-safety.min.md +11 -0
- package/templates/codex/skills/gx-act/SKILL.md +82 -0
- package/templates/githooks/pre-commit +22 -19
- package/templates/scripts/agent-branch-finish.sh +8 -30
- package/templates/scripts/agent-branch-merge.sh +4 -1
- package/templates/scripts/agent-branch-start.sh +88 -3
- package/templates/scripts/agent-preflight.sh +31 -5
- package/templates/scripts/agent-worktree-prune.sh +1 -1
- package/templates/scripts/codex-agent.sh +0 -91
- package/src/agents/detect.js +0 -160
- package/src/cockpit/keybindings.js +0 -224
- 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 ||
|
|
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
|
-
|
|
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
|
|
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,
|