@imdeadpool/guardex 7.0.20 → 7.0.22

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.
@@ -1,425 +1,523 @@
1
- function createFinishApi(deps) {
2
- const {
3
- TOOL_NAME,
4
- LOCK_FILE_RELATIVE,
5
- path,
6
- fs,
7
- run,
8
- runPackageAsset,
9
- resolveRepoRoot,
10
- parseCleanupArgs,
11
- parseMergeArgs,
12
- parseFinishArgs,
13
- parseSyncArgs,
14
- listAgentWorktrees,
15
- listLocalAgentBranchesForFinish,
16
- uniquePreserveOrder,
17
- branchExists,
18
- resolveFinishBaseBranch,
19
- worktreeHasLocalChanges,
20
- branchMergedIntoBase,
21
- autoCommitWorktreeForFinish,
22
- resolveBaseBranch,
23
- resolveSyncStrategy,
24
- ensureOriginBaseRef,
25
- gitRun,
26
- currentBranchName,
27
- workingTreeIsDirty,
28
- aheadBehind,
29
- lockRegistryStatus,
30
- syncOperation,
31
- } = deps;
32
-
33
- function cleanup(rawArgs) {
34
- const options = parseCleanupArgs(rawArgs);
35
- const repoRoot = resolveRepoRoot(options.target);
36
-
37
- const args = [];
38
- if (options.base) {
39
- args.push('--base', options.base);
40
- }
41
- if (options.branch) {
42
- args.push('--branch', options.branch);
43
- }
44
- if (options.forceDirty) {
45
- args.push('--force-dirty');
46
- }
47
- if (options.dryRun) {
48
- args.push('--dry-run');
49
- }
50
- if (!options.keepCleanWorktrees) {
51
- args.push('--only-dirty-worktrees');
52
- }
53
- if (options.includePrMerged) {
54
- args.push('--include-pr-merged');
55
- }
56
- if (options.idleMinutes > 0) {
57
- args.push('--idle-minutes', String(options.idleMinutes));
58
- }
59
- if (options.maxBranches > 0) {
60
- args.push('--max-branches', String(options.maxBranches));
61
- }
62
- args.push('--delete-branches');
63
- if (!options.keepRemote) {
64
- args.push('--delete-remote-branches');
1
+ const { TOOL_NAME, LOCK_FILE_RELATIVE, path, fs } = require('../context');
2
+ const { run, runPackageAsset } = require('../core/runtime');
3
+ const {
4
+ resolveRepoRoot,
5
+ uniquePreserveOrder,
6
+ listAgentWorktrees,
7
+ listLocalAgentBranchesForFinish,
8
+ branchExists,
9
+ resolveFinishBaseBranch,
10
+ worktreeHasLocalChanges,
11
+ branchMergedIntoBase,
12
+ resolveBaseBranch,
13
+ resolveSyncStrategy,
14
+ ensureOriginBaseRef,
15
+ gitRun,
16
+ currentBranchName,
17
+ workingTreeIsDirty,
18
+ aheadBehind,
19
+ lockRegistryStatus,
20
+ syncOperation,
21
+ gitOutputLines,
22
+ } = require('../git');
23
+ const {
24
+ parseCleanupArgs,
25
+ parseMergeArgs,
26
+ parseFinishArgs,
27
+ parseSyncArgs,
28
+ } = require('../cli/args');
29
+
30
+ function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
31
+ const changedFiles = uniquePreserveOrder([
32
+ ...gitOutputLines(worktreePath, ['diff', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
33
+ ...gitOutputLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
34
+ ...gitOutputLines(worktreePath, ['ls-files', '--others', '--exclude-standard']),
35
+ ]);
36
+
37
+ if (changedFiles.length > 0) {
38
+ const claim = runPackageAsset('lockTool', ['claim', '--branch', branch, ...changedFiles], {
39
+ cwd: repoRoot,
40
+ stdio: 'pipe',
41
+ });
42
+ if (claim.status !== 0) {
43
+ throw new Error(
44
+ `Lock claim failed for ${branch}: ${(
45
+ claim.stderr || claim.stdout || ''
46
+ ).trim()}`,
47
+ );
65
48
  }
49
+ }
66
50
 
67
- const runCleanupCycle = () => {
68
- const runResult = runPackageAsset('worktreePrune', args, { cwd: repoRoot, stdio: 'inherit' });
69
- if (runResult.status !== 0) {
70
- throw new Error('Cleanup command failed');
71
- }
72
- };
73
-
74
- if (options.watch) {
75
- let cycle = 0;
76
- while (true) {
77
- cycle += 1;
78
- console.log(
79
- `[${TOOL_NAME}] Cleanup watch cycle=${cycle} (interval=${options.intervalSeconds}s, idleMinutes=${options.idleMinutes}, maxBranches=${options.maxBranches > 0 ? options.maxBranches : 'unbounded'}).`,
80
- );
81
- runCleanupCycle();
82
- if (options.once) {
83
- break;
84
- }
85
- const sleepResult = run('sleep', [String(options.intervalSeconds)], { cwd: repoRoot });
86
- if (sleepResult.status !== 0) {
87
- throw new Error(`Cleanup watch sleep failed (interval=${options.intervalSeconds}s)`);
88
- }
89
- }
90
- process.exitCode = 0;
91
- return;
51
+ const deletedFiles = uniquePreserveOrder([
52
+ ...gitOutputLines(worktreePath, [
53
+ 'diff',
54
+ '--name-only',
55
+ '--diff-filter=D',
56
+ '--',
57
+ '.',
58
+ ':(exclude).omx/state/agent-file-locks.json',
59
+ ]),
60
+ ...gitOutputLines(worktreePath, [
61
+ 'diff',
62
+ '--cached',
63
+ '--name-only',
64
+ '--diff-filter=D',
65
+ '--',
66
+ '.',
67
+ ':(exclude).omx/state/agent-file-locks.json',
68
+ ]),
69
+ ]);
70
+
71
+ if (deletedFiles.length > 0) {
72
+ const allowDelete = runPackageAsset('lockTool', ['allow-delete', '--branch', branch, ...deletedFiles], {
73
+ cwd: repoRoot,
74
+ stdio: 'pipe',
75
+ });
76
+ if (allowDelete.status !== 0) {
77
+ throw new Error(
78
+ `Delete-lock grant failed for ${branch}: ${(
79
+ allowDelete.stderr || allowDelete.stdout || ''
80
+ ).trim()}`,
81
+ );
92
82
  }
83
+ }
84
+ }
93
85
 
94
- runCleanupCycle();
95
- process.exitCode = 0;
86
+ function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
87
+ const hasChanges = worktreeHasLocalChanges(worktreePath);
88
+ if (!hasChanges) {
89
+ return { changed: false, committed: false };
96
90
  }
97
91
 
98
- function merge(rawArgs) {
99
- const options = parseMergeArgs(rawArgs);
100
- const repoRoot = resolveRepoRoot(options.target);
92
+ if (options.noAutoCommit) {
93
+ throw new Error(
94
+ `Branch '${branch}' has local changes in ${worktreePath}. Re-run without --no-auto-commit or commit manually first.`,
95
+ );
96
+ }
101
97
 
102
- const args = [];
103
- if (options.base) {
104
- args.push('--base', options.base);
105
- }
106
- if (options.into) {
107
- args.push('--into', options.into);
108
- }
109
- if (options.task) {
110
- args.push('--task', options.task);
111
- }
112
- if (options.agent) {
113
- args.push('--agent', options.agent);
114
- }
115
- for (const branch of options.branches) {
116
- args.push('--branch', branch);
117
- }
98
+ if (options.dryRun) {
99
+ return { changed: true, committed: false, dryRun: true };
100
+ }
118
101
 
119
- const mergeResult = runPackageAsset('branchMerge', args, { cwd: repoRoot, stdio: 'pipe' });
120
- if (mergeResult.stdout) {
121
- process.stdout.write(mergeResult.stdout);
122
- }
123
- if (mergeResult.stderr) {
124
- process.stderr.write(mergeResult.stderr);
125
- }
126
- if (mergeResult.status !== 0) {
127
- throw new Error(`merge command failed with status ${mergeResult.status}`);
128
- }
102
+ claimLocksForAutoCommit(repoRoot, worktreePath, branch);
129
103
 
130
- process.exitCode = 0;
104
+ const addResult = run('git', ['-C', worktreePath, 'add', '-A'], { stdio: 'pipe' });
105
+ if (addResult.status !== 0) {
106
+ throw new Error(`git add failed in ${worktreePath}: ${(addResult.stderr || addResult.stdout || '').trim()}`);
131
107
  }
132
108
 
133
- function finish(rawArgs, defaults = {}) {
134
- const options = parseFinishArgs(rawArgs, defaults);
135
- const repoRoot = resolveRepoRoot(options.target);
109
+ const stagedHasChanges = run('git', [
110
+ '-C',
111
+ worktreePath,
112
+ 'diff',
113
+ '--cached',
114
+ '--quiet',
115
+ '--',
116
+ '.',
117
+ ':(exclude).omx/state/agent-file-locks.json',
118
+ ], { stdio: 'pipe' }).status === 1;
119
+ if (!stagedHasChanges) {
120
+ return { changed: true, committed: false };
121
+ }
136
122
 
137
- const worktreeEntries = listAgentWorktrees(repoRoot);
138
- const worktreeByBranch = new Map(worktreeEntries.map((entry) => [entry.branch, entry.worktreePath]));
123
+ const commitMessage = options.commitMessage || `Auto-finish: ${branch}`;
124
+ const commitResult = run('git', ['-C', worktreePath, 'commit', '-m', commitMessage], { stdio: 'pipe' });
125
+ if (commitResult.status !== 0) {
126
+ throw new Error(
127
+ `Auto-commit failed on '${branch}': ${(
128
+ commitResult.stderr || commitResult.stdout || ''
129
+ ).trim()}`,
130
+ );
131
+ }
139
132
 
140
- let candidateBranches = [];
141
- if (options.branch) {
142
- if (!branchExists(repoRoot, options.branch)) {
143
- throw new Error(`Local branch not found: ${options.branch}`);
144
- }
145
- candidateBranches = [options.branch];
146
- } else {
147
- candidateBranches = uniquePreserveOrder([
148
- ...listLocalAgentBranchesForFinish(repoRoot),
149
- ...worktreeEntries.map((entry) => entry.branch),
150
- ]);
151
- }
133
+ return { changed: true, committed: true, message: commitMessage };
134
+ }
152
135
 
153
- const candidates = [];
154
- for (const branch of candidateBranches) {
155
- const worktreePath = worktreeByBranch.get(branch) || '';
156
- const baseBranch = resolveFinishBaseBranch(repoRoot, branch, options.base);
157
- const hasChanges = worktreePath ? worktreeHasLocalChanges(worktreePath) : false;
158
- const alreadyMerged = branchMergedIntoBase(repoRoot, branch, baseBranch);
159
- if (options.all || options.branch || hasChanges || !alreadyMerged) {
160
- candidates.push({
161
- branch,
162
- baseBranch,
163
- worktreePath,
164
- hasChanges,
165
- alreadyMerged,
166
- });
167
- }
168
- }
136
+ function cleanup(rawArgs) {
137
+ const options = parseCleanupArgs(rawArgs);
138
+ const repoRoot = resolveRepoRoot(options.target);
169
139
 
170
- if (candidates.length === 0) {
171
- console.log(`[${TOOL_NAME}] No pending agent branches to finish.`);
172
- process.exitCode = 0;
173
- return;
174
- }
140
+ const args = [];
141
+ if (options.base) {
142
+ args.push('--base', options.base);
143
+ }
144
+ if (options.branch) {
145
+ args.push('--branch', options.branch);
146
+ }
147
+ if (options.forceDirty) {
148
+ args.push('--force-dirty');
149
+ }
150
+ if (options.dryRun) {
151
+ args.push('--dry-run');
152
+ }
153
+ if (!options.keepCleanWorktrees) {
154
+ args.push('--only-dirty-worktrees');
155
+ }
156
+ if (options.includePrMerged) {
157
+ args.push('--include-pr-merged');
158
+ }
159
+ if (options.idleMinutes > 0) {
160
+ args.push('--idle-minutes', String(options.idleMinutes));
161
+ }
162
+ if (options.maxBranches > 0) {
163
+ args.push('--max-branches', String(options.maxBranches));
164
+ }
165
+ args.push('--delete-branches');
166
+ if (!options.keepRemote) {
167
+ args.push('--delete-remote-branches');
168
+ }
175
169
 
176
- let succeeded = 0;
177
- let failed = 0;
178
- let autoCommitted = 0;
170
+ const runCleanupCycle = () => {
171
+ const runResult = runPackageAsset('worktreePrune', args, { cwd: repoRoot, stdio: 'inherit' });
172
+ if (runResult.status !== 0) {
173
+ throw new Error('Cleanup command failed');
174
+ }
175
+ };
179
176
 
180
- for (const candidate of candidates) {
181
- const { branch, baseBranch, worktreePath } = candidate;
177
+ if (options.watch) {
178
+ let cycle = 0;
179
+ while (true) {
180
+ cycle += 1;
182
181
  console.log(
183
- `[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
182
+ `[${TOOL_NAME}] Cleanup watch cycle=${cycle} (interval=${options.intervalSeconds}s, idleMinutes=${options.idleMinutes}, maxBranches=${options.maxBranches > 0 ? options.maxBranches : 'unbounded'}).`,
184
183
  );
184
+ runCleanupCycle();
185
+ if (options.once) {
186
+ break;
187
+ }
188
+ const sleepResult = run('sleep', [String(options.intervalSeconds)], { cwd: repoRoot });
189
+ if (sleepResult.status !== 0) {
190
+ throw new Error(`Cleanup watch sleep failed (interval=${options.intervalSeconds}s)`);
191
+ }
192
+ }
193
+ process.exitCode = 0;
194
+ return;
195
+ }
185
196
 
186
- try {
187
- let commitState = { changed: false, committed: false };
188
- if (worktreePath) {
189
- commitState = autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options);
190
- }
197
+ runCleanupCycle();
198
+ process.exitCode = 0;
199
+ }
191
200
 
192
- if (commitState.committed) {
193
- autoCommitted += 1;
194
- console.log(`[${TOOL_NAME}] Auto-committed '${branch}' before finish.`);
195
- } else if (commitState.changed && commitState.dryRun) {
196
- console.log(`[${TOOL_NAME}] [dry-run] Would auto-commit pending changes on '${branch}'.`);
197
- }
201
+ function merge(rawArgs) {
202
+ const options = parseMergeArgs(rawArgs);
203
+ const repoRoot = resolveRepoRoot(options.target);
198
204
 
199
- const finishArgs = [
200
- '--branch',
201
- branch,
202
- '--base',
203
- baseBranch,
204
- options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
205
- options.cleanup ? '--cleanup' : '--no-cleanup',
206
- ];
207
- if (options.mergeMode === 'pr') {
208
- finishArgs.push('--via-pr');
209
- } else if (options.mergeMode === 'direct') {
210
- finishArgs.push('--direct-only');
211
- } else {
212
- finishArgs.push('--mode', 'auto');
213
- }
214
- if (options.keepRemote) {
215
- finishArgs.push('--keep-remote-branch');
216
- }
205
+ const args = [];
206
+ if (options.base) {
207
+ args.push('--base', options.base);
208
+ }
209
+ if (options.into) {
210
+ args.push('--into', options.into);
211
+ }
212
+ if (options.task) {
213
+ args.push('--task', options.task);
214
+ }
215
+ if (options.agent) {
216
+ args.push('--agent', options.agent);
217
+ }
218
+ for (const branch of options.branches) {
219
+ args.push('--branch', branch);
220
+ }
217
221
 
218
- if (options.dryRun) {
219
- console.log(`[${TOOL_NAME}] [dry-run] Would run: gx branch finish ${finishArgs.join(' ')}`);
220
- succeeded += 1;
221
- continue;
222
- }
222
+ const mergeResult = runPackageAsset('branchMerge', args, { cwd: repoRoot, stdio: 'pipe' });
223
+ if (mergeResult.stdout) {
224
+ process.stdout.write(mergeResult.stdout);
225
+ }
226
+ if (mergeResult.stderr) {
227
+ process.stderr.write(mergeResult.stderr);
228
+ }
229
+ if (mergeResult.status !== 0) {
230
+ throw new Error(`merge command failed with status ${mergeResult.status}`);
231
+ }
223
232
 
224
- const finishResult = runPackageAsset('branchFinish', finishArgs, { cwd: repoRoot, stdio: 'pipe' });
225
- if (finishResult.stdout) {
226
- process.stdout.write(finishResult.stdout);
227
- }
228
- if (finishResult.stderr) {
229
- process.stderr.write(finishResult.stderr);
230
- }
231
- if (finishResult.status !== 0) {
232
- throw new Error(`agent-branch-finish exited with status ${finishResult.status}`);
233
- }
233
+ process.exitCode = 0;
234
+ }
234
235
 
235
- succeeded += 1;
236
- } catch (error) {
237
- failed += 1;
238
- console.error(`[${TOOL_NAME}] Finish failed for '${branch}': ${error.message}`);
239
- if (options.failFast) {
240
- break;
241
- }
242
- }
243
- }
236
+ function finish(rawArgs, defaults = {}) {
237
+ const options = parseFinishArgs(rawArgs, defaults);
238
+ const repoRoot = resolveRepoRoot(options.target);
244
239
 
245
- console.log(
246
- `[${TOOL_NAME}] Finish summary: total=${candidates.length}, success=${succeeded}, failed=${failed}, autoCommitted=${autoCommitted}`,
247
- );
240
+ const worktreeEntries = listAgentWorktrees(repoRoot);
241
+ const worktreeByBranch = new Map(worktreeEntries.map((entry) => [entry.branch, entry.worktreePath]));
242
+
243
+ let candidateBranches = [];
244
+ if (options.branch) {
245
+ if (!branchExists(repoRoot, options.branch)) {
246
+ throw new Error(`Local branch not found: ${options.branch}`);
247
+ }
248
+ candidateBranches = [options.branch];
249
+ } else {
250
+ candidateBranches = uniquePreserveOrder([
251
+ ...listLocalAgentBranchesForFinish(repoRoot),
252
+ ...worktreeEntries.map((entry) => entry.branch),
253
+ ]);
254
+ }
248
255
 
249
- if (failed > 0) {
250
- throw new Error('finish command failed for one or more agent branches');
256
+ const candidates = [];
257
+ for (const branch of candidateBranches) {
258
+ const worktreePath = worktreeByBranch.get(branch) || '';
259
+ const baseBranch = resolveFinishBaseBranch(repoRoot, branch, options.base);
260
+ const hasChanges = worktreePath ? worktreeHasLocalChanges(worktreePath) : false;
261
+ const alreadyMerged = branchMergedIntoBase(repoRoot, branch, baseBranch);
262
+ if (options.all || options.branch || hasChanges || !alreadyMerged) {
263
+ candidates.push({
264
+ branch,
265
+ baseBranch,
266
+ worktreePath,
267
+ hasChanges,
268
+ alreadyMerged,
269
+ });
251
270
  }
271
+ }
252
272
 
273
+ if (candidates.length === 0) {
274
+ console.log(`[${TOOL_NAME}] No pending agent branches to finish.`);
253
275
  process.exitCode = 0;
276
+ return;
254
277
  }
255
278
 
256
- function sync(rawArgs) {
257
- const options = parseSyncArgs(rawArgs);
258
- const repoRoot = resolveRepoRoot(options.target);
259
- const baseBranch = resolveBaseBranch(repoRoot, options.base);
260
- const strategy = resolveSyncStrategy(repoRoot, options.strategy);
261
- const baseRef = `origin/${baseBranch}`;
279
+ let succeeded = 0;
280
+ let failed = 0;
281
+ let autoCommitted = 0;
262
282
 
263
- ensureOriginBaseRef(repoRoot, baseBranch);
283
+ for (const candidate of candidates) {
284
+ const { branch, baseBranch, worktreePath } = candidate;
285
+ console.log(
286
+ `[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
287
+ );
264
288
 
265
- if (options.allAgentBranches) {
266
- const refs = gitRun(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/*'], { allowFailure: true });
267
- if (refs.status !== 0) {
268
- throw new Error('Unable to list local agent branches');
289
+ try {
290
+ let commitState = { changed: false, committed: false };
291
+ if (worktreePath) {
292
+ commitState = autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options);
269
293
  }
270
- const branches = (refs.stdout || '').split('\n').map((item) => item.trim()).filter(Boolean);
271
- const rows = branches.map((branch) => {
272
- const counts = aheadBehind(repoRoot, branch, baseRef);
273
- return {
274
- branch,
275
- base: baseRef,
276
- ahead: counts.ahead,
277
- behind: counts.behind,
278
- syncRequired: counts.behind > 0,
279
- };
280
- });
281
294
 
282
- if (options.json) {
283
- process.stdout.write(`${JSON.stringify({
284
- repoRoot,
285
- base: baseRef,
286
- branchCount: rows.length,
287
- rows,
288
- }, null, 2)}\n`);
289
- } else {
290
- console.log(`[${TOOL_NAME}] Sync report target: ${repoRoot}`);
291
- console.log(`[${TOOL_NAME}] Base: ${baseRef}`);
292
- if (rows.length === 0) {
293
- console.log(`[${TOOL_NAME}] No local agent branches found.`);
294
- } else {
295
- for (const row of rows) {
296
- console.log(` - ${row.branch} | ahead ${row.ahead} | behind ${row.behind} | syncRequired=${row.syncRequired}`);
297
- }
298
- }
295
+ if (commitState.committed) {
296
+ autoCommitted += 1;
297
+ console.log(`[${TOOL_NAME}] Auto-committed '${branch}' before finish.`);
298
+ } else if (commitState.changed && commitState.dryRun) {
299
+ console.log(`[${TOOL_NAME}] [dry-run] Would auto-commit pending changes on '${branch}'.`);
299
300
  }
300
301
 
301
- const hasBehind = rows.some((row) => row.behind > 0);
302
- process.exitCode = options.check && hasBehind ? 1 : 0;
303
- return;
304
- }
305
-
306
- const branch = currentBranchName(repoRoot);
307
- if (!options.allowNonAgent && !branch.startsWith('agent/')) {
308
- throw new Error(`sync is limited to agent/* branches by default (current: ${branch}). Use --allow-non-agent to override.`);
309
- }
310
-
311
- const dirty = workingTreeIsDirty(repoRoot);
312
- if (!options.check && !options.allowDirty && dirty) {
313
- throw new Error('Sync blocked: working tree is not clean. Commit or stash changes first, or pass --allow-dirty.');
314
- }
315
-
316
- const before = aheadBehind(repoRoot, branch, baseRef);
317
-
318
- const payload = {
319
- repoRoot,
320
- branch,
321
- base: baseRef,
322
- strategy,
323
- dirty,
324
- aheadBefore: before.ahead,
325
- behindBefore: before.behind,
326
- syncRequired: before.behind > 0,
327
- status: 'checked',
328
- };
329
-
330
- if (options.check) {
331
- if (options.json) {
332
- process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
302
+ const finishArgs = [
303
+ '--branch',
304
+ branch,
305
+ '--base',
306
+ baseBranch,
307
+ options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
308
+ options.cleanup ? '--cleanup' : '--no-cleanup',
309
+ ];
310
+ if (options.mergeMode === 'pr') {
311
+ finishArgs.push('--via-pr');
312
+ } else if (options.mergeMode === 'direct') {
313
+ finishArgs.push('--direct-only');
333
314
  } else {
334
- console.log(`[${TOOL_NAME}] Sync check target: ${repoRoot}`);
335
- console.log(`[${TOOL_NAME}] Branch: ${branch}`);
336
- console.log(`[${TOOL_NAME}] Base: ${baseRef}`);
337
- console.log(`[${TOOL_NAME}] Ahead: ${before.ahead}`);
338
- console.log(`[${TOOL_NAME}] Behind: ${before.behind}`);
339
- console.log(`[${TOOL_NAME}] Sync required: ${before.behind > 0 ? 'yes' : 'no'}`);
315
+ finishArgs.push('--mode', 'auto');
316
+ }
317
+ if (options.keepRemote) {
318
+ finishArgs.push('--keep-remote-branch');
340
319
  }
341
- process.exitCode = before.behind > 0 ? 1 : 0;
342
- return;
343
- }
344
320
 
345
- if (before.behind === 0) {
346
- const result = { ...payload, status: 'no-op', aheadAfter: before.ahead, behindAfter: before.behind };
347
- if (options.json) {
348
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
349
- } else {
350
- console.log(`[${TOOL_NAME}] Branch '${branch}' is already up to date with ${baseRef}.`);
321
+ if (options.dryRun) {
322
+ console.log(`[${TOOL_NAME}] [dry-run] Would run: gx branch finish ${finishArgs.join(' ')}`);
323
+ succeeded += 1;
324
+ continue;
351
325
  }
352
- process.exitCode = 0;
353
- return;
354
- }
355
326
 
356
- if (options.dryRun) {
357
- const result = { ...payload, status: 'dry-run' };
358
- if (options.json) {
359
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
360
- } else {
361
- console.log(`[${TOOL_NAME}] Dry run: would sync '${branch}' onto ${baseRef} via ${strategy}.`);
327
+ const finishResult = runPackageAsset('branchFinish', finishArgs, { cwd: repoRoot, stdio: 'pipe' });
328
+ if (finishResult.stdout) {
329
+ process.stdout.write(finishResult.stdout);
330
+ }
331
+ if (finishResult.stderr) {
332
+ process.stderr.write(finishResult.stderr);
333
+ }
334
+ if (finishResult.status !== 0) {
335
+ throw new Error(`agent-branch-finish exited with status ${finishResult.status}`);
362
336
  }
363
- process.exitCode = 0;
364
- return;
365
- }
366
337
 
367
- const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
368
- const lockState = lockRegistryStatus(repoRoot);
369
- let lockBackup = null;
370
- if (lockState.dirty && fs.existsSync(lockPath)) {
371
- lockBackup = fs.readFileSync(lockPath, 'utf8');
338
+ succeeded += 1;
339
+ } catch (error) {
340
+ failed += 1;
341
+ console.error(`[${TOOL_NAME}] Finish failed for '${branch}': ${error.message}`);
342
+ if (options.failFast) {
343
+ break;
344
+ }
372
345
  }
346
+ }
347
+
348
+ console.log(
349
+ `[${TOOL_NAME}] Finish summary: total=${candidates.length}, success=${succeeded}, failed=${failed}, autoCommitted=${autoCommitted}`,
350
+ );
351
+
352
+ if (failed > 0) {
353
+ throw new Error('finish command failed for one or more agent branches');
354
+ }
355
+
356
+ process.exitCode = 0;
357
+ }
358
+
359
+ function sync(rawArgs) {
360
+ const options = parseSyncArgs(rawArgs);
361
+ const repoRoot = resolveRepoRoot(options.target);
362
+ const baseBranch = resolveBaseBranch(repoRoot, options.base);
363
+ const strategy = resolveSyncStrategy(repoRoot, options.strategy);
364
+ const baseRef = `origin/${baseBranch}`;
365
+
366
+ ensureOriginBaseRef(repoRoot, baseBranch);
367
+
368
+ if (options.allAgentBranches) {
369
+ const refs = gitRun(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/*'], { allowFailure: true });
370
+ if (refs.status !== 0) {
371
+ throw new Error('Unable to list local agent branches');
372
+ }
373
+ const branches = (refs.stdout || '').split('\n').map((item) => item.trim()).filter(Boolean);
374
+ const rows = branches.map((branch) => {
375
+ const counts = aheadBehind(repoRoot, branch, baseRef);
376
+ return {
377
+ branch,
378
+ base: baseRef,
379
+ ahead: counts.ahead,
380
+ behind: counts.behind,
381
+ syncRequired: counts.behind > 0,
382
+ };
383
+ });
373
384
 
374
- if (lockState.dirty) {
375
- if (lockState.untracked) {
376
- fs.rmSync(lockPath, { force: true });
385
+ if (options.json) {
386
+ process.stdout.write(`${JSON.stringify({
387
+ repoRoot,
388
+ base: baseRef,
389
+ branchCount: rows.length,
390
+ rows,
391
+ }, null, 2)}\n`);
392
+ } else {
393
+ console.log(`[${TOOL_NAME}] Sync report target: ${repoRoot}`);
394
+ console.log(`[${TOOL_NAME}] Base: ${baseRef}`);
395
+ if (rows.length === 0) {
396
+ console.log(`[${TOOL_NAME}] No local agent branches found.`);
377
397
  } else {
378
- const resetLock = gitRun(repoRoot, ['checkout', '--', LOCK_FILE_RELATIVE], { allowFailure: true });
379
- if (resetLock.status !== 0) {
380
- throw new Error(`Unable to temporarily reset ${LOCK_FILE_RELATIVE} before sync`);
398
+ for (const row of rows) {
399
+ console.log(` - ${row.branch} | ahead ${row.ahead} | behind ${row.behind} | syncRequired=${row.syncRequired}`);
381
400
  }
382
401
  }
383
402
  }
384
403
 
385
- try {
386
- syncOperation(repoRoot, strategy, baseRef, options.ffOnly);
387
- } finally {
388
- if (lockBackup !== null) {
389
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
390
- fs.writeFileSync(lockPath, lockBackup, 'utf8');
391
- }
392
- }
393
- const after = aheadBehind(repoRoot, branch, baseRef);
394
- const result = {
395
- ...payload,
396
- status: 'success',
397
- aheadAfter: after.ahead,
398
- behindAfter: after.behind,
399
- };
404
+ const hasBehind = rows.some((row) => row.behind > 0);
405
+ process.exitCode = options.check && hasBehind ? 1 : 0;
406
+ return;
407
+ }
400
408
 
409
+ const branch = currentBranchName(repoRoot);
410
+ if (!options.allowNonAgent && !branch.startsWith('agent/')) {
411
+ throw new Error(`sync is limited to agent/* branches by default (current: ${branch}). Use --allow-non-agent to override.`);
412
+ }
413
+
414
+ const dirty = workingTreeIsDirty(repoRoot);
415
+ if (!options.check && !options.allowDirty && dirty) {
416
+ throw new Error('Sync blocked: working tree is not clean. Commit or stash changes first, or pass --allow-dirty.');
417
+ }
418
+
419
+ const before = aheadBehind(repoRoot, branch, baseRef);
420
+
421
+ const payload = {
422
+ repoRoot,
423
+ branch,
424
+ base: baseRef,
425
+ strategy,
426
+ dirty,
427
+ aheadBefore: before.ahead,
428
+ behindBefore: before.behind,
429
+ syncRequired: before.behind > 0,
430
+ status: 'checked',
431
+ };
432
+
433
+ if (options.check) {
401
434
  if (options.json) {
402
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
435
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
403
436
  } else {
404
- console.log(`[${TOOL_NAME}] Sync target: ${repoRoot}`);
437
+ console.log(`[${TOOL_NAME}] Sync check target: ${repoRoot}`);
405
438
  console.log(`[${TOOL_NAME}] Branch: ${branch}`);
406
439
  console.log(`[${TOOL_NAME}] Base: ${baseRef}`);
407
- console.log(`[${TOOL_NAME}] Strategy: ${strategy}`);
408
- console.log(`[${TOOL_NAME}] Behind before sync: ${before.behind}`);
409
- console.log(`[${TOOL_NAME}] Result: success (behind now: ${after.behind})`);
440
+ console.log(`[${TOOL_NAME}] Ahead: ${before.ahead}`);
441
+ console.log(`[${TOOL_NAME}] Behind: ${before.behind}`);
442
+ console.log(`[${TOOL_NAME}] Sync required: ${before.behind > 0 ? 'yes' : 'no'}`);
443
+ }
444
+ process.exitCode = before.behind > 0 ? 1 : 0;
445
+ return;
446
+ }
447
+
448
+ if (before.behind === 0) {
449
+ const result = { ...payload, status: 'no-op', aheadAfter: before.ahead, behindAfter: before.behind };
450
+ if (options.json) {
451
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
452
+ } else {
453
+ console.log(`[${TOOL_NAME}] Branch '${branch}' is already up to date with ${baseRef}.`);
410
454
  }
455
+ process.exitCode = 0;
456
+ return;
457
+ }
411
458
 
459
+ if (options.dryRun) {
460
+ const result = { ...payload, status: 'dry-run' };
461
+ if (options.json) {
462
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
463
+ } else {
464
+ console.log(`[${TOOL_NAME}] Dry run: would sync '${branch}' onto ${baseRef} via ${strategy}.`);
465
+ }
412
466
  process.exitCode = 0;
467
+ return;
413
468
  }
414
469
 
415
- return {
416
- cleanup,
417
- merge,
418
- finish,
419
- sync,
470
+ const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
471
+ const lockState = lockRegistryStatus(repoRoot);
472
+ let lockBackup = null;
473
+ if (lockState.dirty && fs.existsSync(lockPath)) {
474
+ lockBackup = fs.readFileSync(lockPath, 'utf8');
475
+ }
476
+
477
+ if (lockState.dirty) {
478
+ if (lockState.untracked) {
479
+ fs.rmSync(lockPath, { force: true });
480
+ } else {
481
+ const resetLock = gitRun(repoRoot, ['checkout', '--', LOCK_FILE_RELATIVE], { allowFailure: true });
482
+ if (resetLock.status !== 0) {
483
+ throw new Error(`Unable to temporarily reset ${LOCK_FILE_RELATIVE} before sync`);
484
+ }
485
+ }
486
+ }
487
+
488
+ try {
489
+ syncOperation(repoRoot, strategy, baseRef, options.ffOnly);
490
+ } finally {
491
+ if (lockBackup !== null) {
492
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
493
+ fs.writeFileSync(lockPath, lockBackup, 'utf8');
494
+ }
495
+ }
496
+ const after = aheadBehind(repoRoot, branch, baseRef);
497
+ const result = {
498
+ ...payload,
499
+ status: 'success',
500
+ aheadAfter: after.ahead,
501
+ behindAfter: after.behind,
420
502
  };
503
+
504
+ if (options.json) {
505
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
506
+ } else {
507
+ console.log(`[${TOOL_NAME}] Sync target: ${repoRoot}`);
508
+ console.log(`[${TOOL_NAME}] Branch: ${branch}`);
509
+ console.log(`[${TOOL_NAME}] Base: ${baseRef}`);
510
+ console.log(`[${TOOL_NAME}] Strategy: ${strategy}`);
511
+ console.log(`[${TOOL_NAME}] Behind before sync: ${before.behind}`);
512
+ console.log(`[${TOOL_NAME}] Result: success (behind now: ${after.behind})`);
513
+ }
514
+
515
+ process.exitCode = 0;
421
516
  }
422
517
 
423
518
  module.exports = {
424
- createFinishApi,
519
+ cleanup,
520
+ merge,
521
+ finish,
522
+ sync,
425
523
  };