@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.
package/src/git/index.js CHANGED
@@ -1,4 +1,16 @@
1
- const { path } = require('../context');
1
+ const fs = require('node:fs');
2
+ const {
3
+ path,
4
+ TOOL_NAME,
5
+ GIT_PROTECTED_BRANCHES_KEY,
6
+ GIT_BASE_BRANCH_KEY,
7
+ GIT_SYNC_STRATEGY_KEY,
8
+ DEFAULT_PROTECTED_BRANCHES,
9
+ DEFAULT_BASE_BRANCH,
10
+ DEFAULT_SYNC_STRATEGY,
11
+ COMPOSE_HINT_FILES,
12
+ LOCK_FILE_RELATIVE,
13
+ } = require('../context');
2
14
  const { run } = require('../core/runtime');
3
15
 
4
16
  function gitRun(repoRoot, args, { allowFailure = false } = {}) {
@@ -41,66 +53,632 @@ const NESTED_REPO_DEFAULT_SKIP_DIRS = new Set([
41
53
  '.pnpm-store',
42
54
  ]);
43
55
 
56
+ function resolveGitCommonDir(repoPath) {
57
+ const result = run('git', ['-C', repoPath, 'rev-parse', '--git-common-dir'], { cwd: repoPath });
58
+ if (result.status !== 0) return null;
59
+ const raw = result.stdout.trim();
60
+ if (!raw) return null;
61
+ return path.resolve(repoPath, raw);
62
+ }
63
+
44
64
  function discoverNestedGitRepos(rootPath, opts = {}) {
45
65
  const maxDepth = Number.isFinite(opts.maxDepth)
46
66
  ? Math.max(1, opts.maxDepth)
47
67
  : NESTED_REPO_DEFAULT_MAX_DEPTH;
48
68
  const extraSkip = new Set(Array.isArray(opts.extraSkip) ? opts.extraSkip : []);
49
69
  const includeSubmodules = Boolean(opts.includeSubmodules);
70
+ const skipRelativeDirs = Array.isArray(opts.skipRelativeDirs) ? opts.skipRelativeDirs.filter(Boolean) : [];
50
71
  const resolvedRoot = path.resolve(rootPath);
51
72
 
52
73
  if (!isGitRepo(resolvedRoot)) {
53
74
  throw new Error(`Target is not inside a git repository: ${resolvedRoot}`);
54
75
  }
55
76
 
56
- const results = [];
57
- const seen = new Set();
58
-
59
- function visit(directoryPath, depth) {
60
- const repoRoot = resolveRepoRoot(directoryPath);
61
- if (!seen.has(repoRoot)) {
62
- seen.add(repoRoot);
63
- results.push(repoRoot);
64
- }
77
+ const rootCommonDir = resolveGitCommonDir(resolvedRoot);
78
+ const skipAbsolutes = skipRelativeDirs.map((relativeDir) => path.join(resolvedRoot, relativeDir));
79
+ const found = new Set([resolvedRoot]);
65
80
 
66
- if (depth >= maxDepth) {
67
- return;
68
- }
81
+ function shouldSkipDir(dirName) {
82
+ return NESTED_REPO_DEFAULT_SKIP_DIRS.has(dirName) || extraSkip.has(dirName);
83
+ }
69
84
 
70
- let entries = [];
85
+ function walk(currentPath, depth) {
86
+ if (depth > maxDepth) return;
87
+ let entries;
71
88
  try {
72
- entries = require('node:fs').readdirSync(directoryPath, { withFileTypes: true });
89
+ entries = fs.readdirSync(currentPath, { withFileTypes: true });
73
90
  } catch {
74
91
  return;
75
92
  }
76
93
 
77
94
  for (const entry of entries) {
78
- if (!entry.isDirectory()) {
95
+ const entryPath = path.join(currentPath, entry.name);
96
+
97
+ if (entry.name === '.git') {
98
+ if (entry.isDirectory()) {
99
+ if (entryPath === path.join(resolvedRoot, '.git')) continue;
100
+ found.add(path.dirname(entryPath));
101
+ } else if (includeSubmodules && entry.isFile()) {
102
+ found.add(path.dirname(entryPath));
103
+ }
79
104
  continue;
80
105
  }
81
- if (NESTED_REPO_DEFAULT_SKIP_DIRS.has(entry.name) || extraSkip.has(entry.name)) {
82
- continue;
106
+
107
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
108
+ if (shouldSkipDir(entry.name)) continue;
109
+ if (skipAbsolutes.includes(entryPath)) continue;
110
+ walk(entryPath, depth + 1);
111
+ }
112
+ }
113
+
114
+ walk(resolvedRoot, 0);
115
+
116
+ const filtered = Array.from(found).filter((repoPath) => {
117
+ if (repoPath === resolvedRoot || !rootCommonDir) return true;
118
+ const childCommonDir = resolveGitCommonDir(repoPath);
119
+ return !childCommonDir || childCommonDir !== rootCommonDir;
120
+ });
121
+
122
+ const [root, ...rest] = filtered;
123
+ rest.sort((a, b) => a.localeCompare(b));
124
+ return root ? [root, ...rest] : [];
125
+ }
126
+
127
+ function parseBranchList(rawValue) {
128
+ return String(rawValue || '')
129
+ .split(/[\s,]+/)
130
+ .map((item) => item.trim())
131
+ .filter(Boolean);
132
+ }
133
+
134
+ function uniquePreserveOrder(items) {
135
+ const seen = new Set();
136
+ const result = [];
137
+ for (const item of items) {
138
+ if (seen.has(item)) continue;
139
+ seen.add(item);
140
+ result.push(item);
141
+ }
142
+ return result;
143
+ }
144
+
145
+ function readConfiguredProtectedBranches(repoRoot) {
146
+ const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
147
+ if (result.status !== 0) {
148
+ return null;
149
+ }
150
+ const parsed = uniquePreserveOrder(parseBranchList(result.stdout.trim()));
151
+ if (parsed.length === 0) {
152
+ return null;
153
+ }
154
+ return parsed;
155
+ }
156
+
157
+ function listLocalUserBranches(repoRoot) {
158
+ const result = gitRun(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { allowFailure: true });
159
+ const branchNames = result.status === 0
160
+ ? uniquePreserveOrder(
161
+ String(result.stdout || '')
162
+ .split('\n')
163
+ .map((item) => item.trim())
164
+ .filter(Boolean),
165
+ )
166
+ : [];
167
+
168
+ const additionalUserBranches = branchNames.filter(
169
+ (branchName) =>
170
+ !branchName.startsWith('agent/') &&
171
+ !DEFAULT_PROTECTED_BRANCHES.includes(branchName),
172
+ );
173
+ if (additionalUserBranches.length > 0) {
174
+ return additionalUserBranches;
175
+ }
176
+
177
+ const current = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true });
178
+ if (current.status !== 0) {
179
+ return [];
180
+ }
181
+
182
+ const branchName = String(current.stdout || '').trim();
183
+ if (
184
+ !branchName ||
185
+ branchName.startsWith('agent/') ||
186
+ DEFAULT_PROTECTED_BRANCHES.includes(branchName)
187
+ ) {
188
+ return [];
189
+ }
190
+
191
+ return [branchName];
192
+ }
193
+
194
+ function listLocalAgentBranches(repoRoot) {
195
+ const result = gitRun(
196
+ repoRoot,
197
+ ['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/'],
198
+ { allowFailure: true },
199
+ );
200
+ if (result.status !== 0) {
201
+ return [];
202
+ }
203
+ return uniquePreserveOrder(
204
+ String(result.stdout || '')
205
+ .split('\n')
206
+ .map((item) => item.trim())
207
+ .filter(Boolean),
208
+ );
209
+ }
210
+
211
+ function mapWorktreePathsByBranch(repoRoot) {
212
+ const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
213
+ const map = new Map();
214
+ if (result.status !== 0) {
215
+ return map;
216
+ }
217
+
218
+ const lines = String(result.stdout || '').split('\n');
219
+ let currentWorktree = '';
220
+ for (const line of lines) {
221
+ if (line.startsWith('worktree ')) {
222
+ currentWorktree = line.slice('worktree '.length).trim();
223
+ continue;
224
+ }
225
+ if (line.startsWith('branch refs/heads/')) {
226
+ const branchName = line.slice('branch refs/heads/'.length).trim();
227
+ if (currentWorktree && branchName) {
228
+ map.set(branchName, currentWorktree);
83
229
  }
230
+ }
231
+ }
232
+ return map;
233
+ }
84
234
 
85
- const childPath = path.join(directoryPath, entry.name);
86
- const gitDir = path.join(childPath, '.git');
87
- if (require('node:fs').existsSync(gitDir)) {
88
- if (!includeSubmodules) {
89
- const gitInfo = require('node:fs').lstatSync(gitDir);
90
- if (gitInfo.isFile()) {
91
- continue;
92
- }
93
- }
94
- visit(childPath, depth + 1);
95
- continue;
235
+ function gitRefExists(repoRoot, ref) {
236
+ return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
237
+ }
238
+
239
+ function hasSignificantWorkingTreeChanges(worktreePath) {
240
+ const result = run('git', [
241
+ '-C',
242
+ worktreePath,
243
+ 'status',
244
+ '--porcelain',
245
+ '--untracked-files=normal',
246
+ '--',
247
+ ]);
248
+ if (result.status !== 0) {
249
+ return true;
250
+ }
251
+
252
+ const lines = String(result.stdout || '')
253
+ .split('\n')
254
+ .map((line) => line.trimEnd())
255
+ .filter((line) => line.length > 0);
256
+
257
+ for (const line of lines) {
258
+ const pathPart = (line.length > 3 ? line.slice(3) : '').trim();
259
+ if (!pathPart) continue;
260
+ if (pathPart === LOCK_FILE_RELATIVE) continue;
261
+ if (pathPart.startsWith(`${LOCK_FILE_RELATIVE} -> `)) continue;
262
+ if (pathPart.endsWith(` -> ${LOCK_FILE_RELATIVE}`)) continue;
263
+ return true;
264
+ }
265
+ return false;
266
+ }
267
+
268
+ function readProtectedBranches(repoRoot) {
269
+ const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
270
+ if (result.status !== 0) {
271
+ return [...DEFAULT_PROTECTED_BRANCHES];
272
+ }
273
+
274
+ const parsed = uniquePreserveOrder(parseBranchList(result.stdout.trim()));
275
+ if (parsed.length === 0) {
276
+ return [...DEFAULT_PROTECTED_BRANCHES];
277
+ }
278
+ return parsed;
279
+ }
280
+
281
+ function ensureSetupProtectedBranches(repoRoot, dryRun) {
282
+ const localUserBranches = listLocalUserBranches(repoRoot);
283
+ if (localUserBranches.length === 0) {
284
+ return {
285
+ status: 'unchanged',
286
+ file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
287
+ note: 'no additional local user branches detected',
288
+ };
289
+ }
290
+
291
+ const configured = readConfiguredProtectedBranches(repoRoot);
292
+ const currentBranches = configured || [...DEFAULT_PROTECTED_BRANCHES];
293
+ const missingBranches = localUserBranches.filter((branchName) => !currentBranches.includes(branchName));
294
+ if (missingBranches.length === 0) {
295
+ return {
296
+ status: 'unchanged',
297
+ file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
298
+ note: 'local user branches already protected',
299
+ };
300
+ }
301
+
302
+ const nextBranches = uniquePreserveOrder([...currentBranches, ...missingBranches]);
303
+ if (!dryRun) {
304
+ writeProtectedBranches(repoRoot, nextBranches);
305
+ }
306
+
307
+ return {
308
+ status: dryRun ? 'would-update' : 'updated',
309
+ file: `git config ${GIT_PROTECTED_BRANCHES_KEY}`,
310
+ note: `added local user branch(es): ${missingBranches.join(', ')}`,
311
+ };
312
+ }
313
+
314
+ function writeProtectedBranches(repoRoot, branches) {
315
+ if (branches.length === 0) {
316
+ gitRun(repoRoot, ['config', '--unset-all', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
317
+ return;
318
+ }
319
+ gitRun(repoRoot, ['config', GIT_PROTECTED_BRANCHES_KEY, branches.join(' ')]);
320
+ }
321
+
322
+ function readGitConfig(repoRoot, key) {
323
+ const result = gitRun(repoRoot, ['config', '--get', key], { allowFailure: true });
324
+ if (result.status !== 0) {
325
+ return '';
326
+ }
327
+ return (result.stdout || '').trim();
328
+ }
329
+
330
+ function resolveBaseBranch(repoRoot, explicitBase) {
331
+ if (explicitBase) {
332
+ return explicitBase;
333
+ }
334
+ const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
335
+ return configured || DEFAULT_BASE_BRANCH;
336
+ }
337
+
338
+ function resolveSyncStrategy(repoRoot, explicitStrategy) {
339
+ const strategy = (explicitStrategy || readGitConfig(repoRoot, GIT_SYNC_STRATEGY_KEY) || DEFAULT_SYNC_STRATEGY)
340
+ .trim()
341
+ .toLowerCase();
342
+ if (strategy !== 'rebase' && strategy !== 'merge') {
343
+ throw new Error(`Invalid sync strategy '${strategy}' (expected: rebase or merge)`);
344
+ }
345
+ return strategy;
346
+ }
347
+
348
+ function currentBranchName(repoRoot) {
349
+ const result = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true });
350
+ if (result.status !== 0) {
351
+ throw new Error('Unable to detect current branch');
352
+ }
353
+ const branch = (result.stdout || '').trim();
354
+ if (!branch) {
355
+ throw new Error('Detached HEAD is not supported for sync operations');
356
+ }
357
+ return branch;
358
+ }
359
+
360
+ function repoHasHeadCommit(repoRoot) {
361
+ return gitRun(repoRoot, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0;
362
+ }
363
+
364
+ function readBranchDisplayName(repoRoot) {
365
+ const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { allowFailure: true });
366
+ if (symbolic.status === 0) {
367
+ const branch = String(symbolic.stdout || '').trim();
368
+ if (!branch) {
369
+ return '(unknown)';
370
+ }
371
+ return repoHasHeadCommit(repoRoot) ? branch : `${branch} (unborn; no commits yet)`;
372
+ }
373
+
374
+ const detached = gitRun(repoRoot, ['rev-parse', '--short', 'HEAD'], { allowFailure: true });
375
+ if (detached.status === 0) {
376
+ return `(detached at ${String(detached.stdout || '').trim()})`;
377
+ }
378
+ return '(unknown)';
379
+ }
380
+
381
+ function hasOriginRemote(repoRoot) {
382
+ return gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
383
+ }
384
+
385
+ function detectComposeHintFiles(repoRoot) {
386
+ return COMPOSE_HINT_FILES.filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
387
+ }
388
+
389
+ function printSetupRepoHints(repoRoot, baseBranch, repoLabel = '') {
390
+ const branchDisplay = readBranchDisplayName(repoRoot);
391
+ const hasHeadCommit = repoHasHeadCommit(repoRoot);
392
+ const hasOrigin = hasOriginRemote(repoRoot);
393
+ const composeFiles = detectComposeHintFiles(repoRoot);
394
+ if (hasHeadCommit && hasOrigin && composeFiles.length === 0) {
395
+ return;
396
+ }
397
+
398
+ const label = repoLabel ? ` ${repoLabel}` : '';
399
+ if (!hasHeadCommit) {
400
+ console.log(`[${TOOL_NAME}] Fresh repo onboarding${label}: current branch is ${branchDisplay}.`);
401
+ console.log(`[${TOOL_NAME}] Bootstrap commit${label}: git add . && git commit -m "bootstrap gitguardex"`);
402
+ console.log(
403
+ `[${TOOL_NAME}] First agent flow${label}: ` +
404
+ `gx branch start "<task>" "codex" -> ` +
405
+ `gx locks claim --branch "$(git branch --show-current)" <file...> -> ` +
406
+ `gx branch finish --branch "$(git branch --show-current)" --base ${baseBranch} --via-pr --wait-for-merge`,
407
+ );
408
+ }
409
+ if (!hasOrigin) {
410
+ console.log(`[${TOOL_NAME}] No origin remote${label}: finish and auto-merge flows stay local until you add one.`);
411
+ }
412
+ if (composeFiles.length > 0) {
413
+ console.log(
414
+ `[${TOOL_NAME}] Docker Compose helper${label}: detected ${composeFiles.join(', ')}. ` +
415
+ `Set GUARDEX_DOCKER_SERVICE and run 'bash scripts/guardex-docker-loader.sh -- <command...>'.`,
416
+ );
417
+ }
418
+ }
419
+
420
+ function workingTreeIsDirty(repoRoot) {
421
+ const result = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
422
+ if (result.status !== 0) {
423
+ throw new Error('Unable to inspect git working tree status');
424
+ }
425
+ const lines = (result.stdout || '').split('\n').filter((line) => line.length > 0);
426
+ const significant = lines.filter((line) => {
427
+ const pathPart = (line.length > 3 ? line.slice(3) : '').trim();
428
+ if (!pathPart) return false;
429
+ if (pathPart === LOCK_FILE_RELATIVE) return false;
430
+ if (pathPart.startsWith(`${LOCK_FILE_RELATIVE} -> `)) return false;
431
+ if (pathPart.endsWith(` -> ${LOCK_FILE_RELATIVE}`)) return false;
432
+ return true;
433
+ });
434
+ return significant.length > 0;
435
+ }
436
+
437
+ function ensureRepoBranch(repoRoot, branch) {
438
+ const current = currentBranchName(repoRoot);
439
+ if (current === branch) {
440
+ return { ok: true, changed: false };
441
+ }
442
+
443
+ const checkoutResult = run('git', ['-C', repoRoot, 'checkout', branch], { timeout: 20_000 });
444
+ if (checkoutResult.error && typeof checkoutResult.status !== 'number') {
445
+ return {
446
+ ok: false,
447
+ changed: false,
448
+ stdout: checkoutResult.stdout || '',
449
+ stderr: checkoutResult.stderr || '',
450
+ };
451
+ }
452
+ if (checkoutResult.status !== 0) {
453
+ return {
454
+ ok: false,
455
+ changed: false,
456
+ stdout: checkoutResult.stdout || '',
457
+ stderr: checkoutResult.stderr || '',
458
+ };
459
+ }
460
+
461
+ return { ok: true, changed: true };
462
+ }
463
+
464
+ function ensureOriginBaseRef(repoRoot, baseBranch) {
465
+ const fetch = gitRun(repoRoot, ['fetch', 'origin', baseBranch, '--quiet'], { allowFailure: true });
466
+ if (fetch.status !== 0) {
467
+ throw new Error(
468
+ `Unable to fetch origin/${baseBranch}. Ensure remote 'origin' exists and branch '${baseBranch}' is available.`,
469
+ );
470
+ }
471
+ const hasRemoteBase = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${baseBranch}`], {
472
+ allowFailure: true,
473
+ });
474
+ if (hasRemoteBase.status !== 0) {
475
+ throw new Error(`Remote base branch not found: origin/${baseBranch}`);
476
+ }
477
+ }
478
+
479
+ function aheadBehind(repoRoot, branchRef, baseRef) {
480
+ const result = gitRun(repoRoot, ['rev-list', '--left-right', '--count', `${branchRef}...${baseRef}`], {
481
+ allowFailure: true,
482
+ });
483
+ if (result.status !== 0) {
484
+ throw new Error(`Unable to compute ahead/behind for ${branchRef} vs ${baseRef}`);
485
+ }
486
+ const parts = (result.stdout || '').trim().split(/\s+/).filter(Boolean);
487
+ const ahead = Number.parseInt(parts[0] || '0', 10);
488
+ const behind = Number.parseInt(parts[1] || '0', 10);
489
+ return { ahead: Number.isFinite(ahead) ? ahead : 0, behind: Number.isFinite(behind) ? behind : 0 };
490
+ }
491
+
492
+ function lockRegistryStatus(repoRoot) {
493
+ const result = gitRun(repoRoot, ['status', '--porcelain', '--', LOCK_FILE_RELATIVE], { allowFailure: true });
494
+ if (result.status !== 0) {
495
+ return { dirty: false, untracked: false };
496
+ }
497
+ const lines = (result.stdout || '').split('\n').filter((line) => line.length > 0);
498
+ if (lines.length === 0) {
499
+ return { dirty: false, untracked: false };
500
+ }
501
+ const untracked = lines.some((line) => line.startsWith('??'));
502
+ return { dirty: true, untracked };
503
+ }
504
+
505
+ function listAgentWorktrees(repoRoot) {
506
+ const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
507
+ if (result.status !== 0) {
508
+ throw new Error('Unable to list git worktrees for finish command');
509
+ }
510
+
511
+ const entries = [];
512
+ let currentPath = '';
513
+ let currentBranchRef = '';
514
+ const lines = String(result.stdout || '').split('\n');
515
+ for (const line of lines) {
516
+ if (!line.trim()) {
517
+ if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
518
+ entries.push({
519
+ worktreePath: currentPath,
520
+ branch: currentBranchRef.replace(/^refs\/heads\//, ''),
521
+ });
96
522
  }
523
+ currentPath = '';
524
+ currentBranchRef = '';
525
+ continue;
526
+ }
527
+ if (line.startsWith('worktree ')) {
528
+ currentPath = line.slice('worktree '.length).trim();
529
+ continue;
530
+ }
531
+ if (line.startsWith('branch ')) {
532
+ currentBranchRef = line.slice('branch '.length).trim();
533
+ continue;
534
+ }
535
+ }
536
+ if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
537
+ entries.push({
538
+ worktreePath: currentPath,
539
+ branch: currentBranchRef.replace(/^refs\/heads\//, ''),
540
+ });
541
+ }
542
+
543
+ return entries;
544
+ }
545
+
546
+ function listLocalAgentBranchesForFinish(repoRoot) {
547
+ return uniquePreserveOrder(
548
+ listLocalAgentBranches(repoRoot).filter((line) => line.startsWith('agent/')),
549
+ );
550
+ }
551
+
552
+ function gitQuietChangeResult(worktreePath, args) {
553
+ const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
554
+ if (result.status === 0) {
555
+ return false;
556
+ }
557
+ if (result.status === 1) {
558
+ return true;
559
+ }
560
+ throw new Error(
561
+ `git ${args.join(' ')} failed in ${worktreePath}: ${(
562
+ result.stderr || result.stdout || ''
563
+ ).trim()}`,
564
+ );
565
+ }
566
+
567
+ function worktreeHasLocalChanges(worktreePath) {
568
+ const hasUnstaged = gitQuietChangeResult(worktreePath, [
569
+ 'diff',
570
+ '--quiet',
571
+ '--',
572
+ '.',
573
+ ':(exclude).omx/state/agent-file-locks.json',
574
+ ]);
575
+ if (hasUnstaged) {
576
+ return true;
577
+ }
578
+
579
+ const hasStaged = gitQuietChangeResult(worktreePath, [
580
+ 'diff',
581
+ '--cached',
582
+ '--quiet',
583
+ '--',
584
+ '.',
585
+ ':(exclude).omx/state/agent-file-locks.json',
586
+ ]);
587
+ if (hasStaged) {
588
+ return true;
589
+ }
590
+
591
+ const untracked = run('git', ['-C', worktreePath, 'ls-files', '--others', '--exclude-standard'], {
592
+ stdio: 'pipe',
593
+ });
594
+ if (untracked.status !== 0) {
595
+ throw new Error(`Unable to inspect untracked files in ${worktreePath}`);
596
+ }
597
+ return String(untracked.stdout || '').trim().length > 0;
598
+ }
97
599
 
98
- visit(childPath, depth + 1);
600
+ function gitOutputLines(worktreePath, args) {
601
+ const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
602
+ if (result.status !== 0) {
603
+ throw new Error(
604
+ `git ${args.join(' ')} failed in ${worktreePath}: ${(
605
+ result.stderr || result.stdout || ''
606
+ ).trim()}`,
607
+ );
608
+ }
609
+ return String(result.stdout || '')
610
+ .split('\n')
611
+ .map((line) => line.trim())
612
+ .filter(Boolean);
613
+ }
614
+
615
+ function branchExists(repoRoot, branch) {
616
+ const result = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
617
+ allowFailure: true,
618
+ });
619
+ return result.status === 0;
620
+ }
621
+
622
+ function resolveFinishBaseBranch(repoRoot, _sourceBranch, explicitBase) {
623
+ if (explicitBase) {
624
+ return explicitBase;
625
+ }
626
+
627
+ const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
628
+ if (configured) {
629
+ return configured;
630
+ }
631
+
632
+ return DEFAULT_BASE_BRANCH;
633
+ }
634
+
635
+ function branchMergedIntoBase(repoRoot, branch, baseBranch) {
636
+ if (!branchExists(repoRoot, baseBranch)) {
637
+ return false;
638
+ }
639
+ const result = gitRun(repoRoot, ['merge-base', '--is-ancestor', branch, baseBranch], {
640
+ allowFailure: true,
641
+ });
642
+ if (result.status === 0) {
643
+ return true;
644
+ }
645
+ if (result.status === 1) {
646
+ return false;
647
+ }
648
+ throw new Error(`Unable to determine merge status for ${branch} -> ${baseBranch}`);
649
+ }
650
+
651
+ function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
652
+ if (strategy === 'rebase') {
653
+ if (ffOnly) {
654
+ throw new Error('--ff-only is only supported with --strategy merge');
655
+ }
656
+ const rebased = run('git', ['-C', repoRoot, 'rebase', baseRef], { stdio: 'pipe' });
657
+ if (rebased.status !== 0) {
658
+ const details = (rebased.stderr || rebased.stdout || '').trim();
659
+ const gitDir = path.join(repoRoot, '.git');
660
+ const rebaseActive = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
661
+ const help = rebaseActive
662
+ ? '\nResolve conflicts, then run: git rebase --continue\nOr abort: git rebase --abort'
663
+ : '';
664
+ throw new Error(`Sync failed during rebase onto ${baseRef}.${details ? `\n${details}` : ''}${help}`);
99
665
  }
666
+ return;
100
667
  }
101
668
 
102
- visit(resolvedRoot, 0);
103
- return results;
669
+ const mergeArgs = ['-C', repoRoot, 'merge', '--no-edit'];
670
+ if (ffOnly) {
671
+ mergeArgs.push('--ff-only');
672
+ }
673
+ mergeArgs.push(baseRef);
674
+ const merged = run('git', mergeArgs, { stdio: 'pipe' });
675
+ if (merged.status !== 0) {
676
+ const details = (merged.stderr || merged.stdout || '').trim();
677
+ const gitDir = path.join(repoRoot, '.git');
678
+ const mergeActive = fs.existsSync(path.join(gitDir, 'MERGE_HEAD'));
679
+ const help = mergeActive ? '\nResolve conflicts, then run: git commit\nOr abort: git merge --abort' : '';
680
+ throw new Error(`Sync failed during merge from ${baseRef}.${details ? `\n${details}` : ''}${help}`);
681
+ }
104
682
  }
105
683
 
106
684
  module.exports = {
@@ -109,4 +687,39 @@ module.exports = {
109
687
  resolveRepoRoot,
110
688
  isGitRepo,
111
689
  discoverNestedGitRepos,
690
+ parseBranchList,
691
+ uniquePreserveOrder,
692
+ readConfiguredProtectedBranches,
693
+ listLocalUserBranches,
694
+ listLocalAgentBranches,
695
+ mapWorktreePathsByBranch,
696
+ gitRefExists,
697
+ hasSignificantWorkingTreeChanges,
698
+ readProtectedBranches,
699
+ ensureSetupProtectedBranches,
700
+ writeProtectedBranches,
701
+ readGitConfig,
702
+ resolveBaseBranch,
703
+ resolveSyncStrategy,
704
+ currentBranchName,
705
+ repoHasHeadCommit,
706
+ readBranchDisplayName,
707
+ hasOriginRemote,
708
+ repoHasOriginRemote: hasOriginRemote,
709
+ detectComposeHintFiles,
710
+ printSetupRepoHints,
711
+ workingTreeIsDirty,
712
+ ensureRepoBranch,
713
+ ensureOriginBaseRef,
714
+ aheadBehind,
715
+ lockRegistryStatus,
716
+ listAgentWorktrees,
717
+ listLocalAgentBranchesForFinish,
718
+ gitQuietChangeResult,
719
+ worktreeHasLocalChanges,
720
+ gitOutputLines,
721
+ branchExists,
722
+ resolveFinishBaseBranch,
723
+ branchMergedIntoBase,
724
+ syncOperation,
112
725
  };