-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdevitems.js
More file actions
2175 lines (2029 loc) · 107 KB
/
Copy pathdevitems.js
File metadata and controls
2175 lines (2029 loc) · 107 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Dev item git layer: app-managed clone + per-item worktree, status, sync.
//
// Model (chosen design): the app maintains ONE managed clone per AzDo repo
// under SUPERVISOR_DATA_DIR/dev-repos/<org>/<project>/<repo>, then adds a git
// worktree per Dev item under SUPERVISOR_DATA_DIR/dev-worktrees/<repo>__<devId>.
// This keeps every Dev item self-contained and avoids touching the user's own
// checkouts.
//
// Auth: network git operations (clone/fetch/pull) inject the Azure DevOps AAD
// bearer token from azdo.getToken() as a one-shot `http.extraheader`, so it
// works regardless of Git Credential Manager / stored PATs. The token is never
// written to disk or into the remote URL.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFileSync, execFile } = require('child_process');
const _execFileP = require('util').promisify(execFile);
const azdo = require('./azdo');
const { forge } = require('./forge');
let SUPERVISOR_DATA_DIR;
try {
SUPERVISOR_DATA_DIR = require('./config-sync').SUPERVISOR_DATA_DIR;
} catch {
SUPERVISOR_DATA_DIR = path.join(process.env.USERPROFILE || process.env.HOME, '.copilot', 'agent-supervisor');
}
const DEV_REPOS = path.join(SUPERVISOR_DATA_DIR, 'dev-repos');
const DEV_WORKTREES = path.join(SUPERVISOR_DATA_DIR, 'dev-worktrees');
// Where new worktrees are created. Prefers the user's configured `worktreeRoot`
// setting; otherwise a SHORT auto path (e.g. C:\a) to maximize Windows MAX_PATH
// (260) headroom for deep repos/obj paths. Existing worktree records store
// absolute paths, so only NEW worktrees follow a changed root. Never throws.
function _shortDefaultRoot() {
try {
if (process.platform === 'win32') {
const root = path.parse(SUPERVISOR_DATA_DIR).root || 'C:\\';
return path.join(root, 'a');
}
} catch {}
return DEV_WORKTREES;
}
function worktreeRoot() {
try {
const s = require('./settings').getSettings();
const r = s && typeof s.worktreeRoot === 'string' ? s.worktreeRoot.trim() : '';
if (r) return r;
} catch {}
return _shortDefaultRoot();
}
function _safe(s) {
return String(s || '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'x';
}
function clonePath(org, project, repo, provider) {
// GitHub sources live under a dedicated github/<owner>/<repo> subtree (GitHub
// has no "project"); Azure DevOps keeps its <org>/<project>/<repo> layout.
if (String(provider || '').toLowerCase() === 'github') {
return path.join(DEV_REPOS, 'github', _safe(org), _safe(repo));
}
return path.join(DEV_REPOS, _safe(org), _safe(project), _safe(repo));
}
function worktreePath(repo, devId) {
return path.join(worktreeRoot(), _safe(repo) + '__' + _safe(devId));
}
// Build host-scoped git auth args for the record's provider (R10 — the
// credential header only applies to that host so a mixed-provider environment
// can't leak one provider's token to another). AzDo uses a bearer token; GitHub
// uses HTTP basic with x-access-token (the gh/OAuth token as the password).
// `desc` is a {provider, org/owner, project, repo} record; null/true => azdo.
function _authArgs(desc) {
const provider = String((desc && desc.provider) || 'azdo').toLowerCase();
if (provider === 'github') {
const token = forge(desc).getToken();
const basic = Buffer.from('x-access-token:' + token).toString('base64');
return ['-c', 'http.https://github.com/.extraheader=AUTHORIZATION: basic ' + basic];
}
return ['-c', 'http.extraheader=AUTHORIZATION: bearer ' + azdo.getTokenSync()];
}
// Never let a git subprocess launch an interactive credential prompt. We supply
// the token ourselves via http.extraheader (see _authArgs), so git needs no
// credential helper at all — disabling it means a genuinely-unauthenticated op
// fails fast with a clean error instead of popping a Git Credential Manager GUI
// window (the "Connect to GitHub" swarm) or hanging on a stale askpass helper.
// `-c credential.helper=` resets the helper list to empty (kills GCM + any
// configured askpass); GIT_TERMINAL_PROMPT=0 blocks git's own terminal prompt;
// GCM_INTERACTIVE=never is belt-and-suspenders for any GCM still in the chain.
const _NO_PROMPT_ARGS = [
'-c', 'credential.helper=',
'-c', 'credential.interactive=false',
'-c', 'core.askPass='
];
function _noPromptEnv() {
const env = Object.assign({}, process.env, {
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never'
});
// A configured askpass helper (GUI) would bypass the above — drop them.
delete env.GIT_ASKPASS;
delete env.SSH_ASKPASS;
return env;
}
// Run a git command, throwing on failure with a clean message.
function _git(args, cwd, { auth = false, timeout = 240_000 } = {}) {
// core.longpaths=true makes git use the \\?\ prefix so deep repo paths
// (e.g. dotnet-helix-machines) don't exceed the Windows MAX_PATH (260) limit
// during clone + worktree checkout.
// `auth` may be false, true (=> azdo default), or a provider descriptor.
const authArgs = auth ? _authArgs(auth === true ? null : auth) : [];
const full = ['-c', 'core.longpaths=true']
.concat(_NO_PROMPT_ARGS)
.concat(authArgs)
.concat(args);
try {
return execFileSync('git', full, {
cwd: cwd || undefined,
encoding: 'utf-8',
timeout,
maxBuffer: 32 * 1024 * 1024,
windowsHide: true,
env: _noPromptEnv()
}).toString();
} catch (e) {
const err = (e.stderr || e.stdout || e.message || '').toString().trim();
const safeArgs = args.join(' ');
throw new Error(`git ${safeArgs} failed: ${err.split('\n').slice(-3).join(' ').slice(0, 400)}`);
}
}
// Run a git command without throwing. Returns { ok, out, err }.
function _gitTry(args, cwd, opts = {}) {
try {
return { ok: true, out: _git(args, cwd, opts).trim(), err: '' };
} catch (e) {
return { ok: false, out: '', err: (e.message || '').toString() };
}
}
// ASYNC twin of _git — same auth/prompt-blocking behaviour, but never blocks the
// Node event loop. Used by the background dev-summary reconciler so a worktree
// whose git is momentarily locked (e.g. the dev agent is mid-commit, holding
// index.lock) can't freeze the entire single-threaded server. The default
// timeout is deliberately short (local read ops only); network callers pass a
// larger one explicitly.
async function _gitAsync(args, cwd, { auth = false, timeout = 60_000 } = {}) {
const authArgs = auth ? _authArgs(auth === true ? null : auth) : [];
const full = ['-c', 'core.longpaths=true']
.concat(_NO_PROMPT_ARGS)
.concat(authArgs)
.concat(args);
try {
const { stdout } = await _execFileP('git', full, {
cwd: cwd || undefined,
encoding: 'utf-8',
timeout,
maxBuffer: 32 * 1024 * 1024,
windowsHide: true,
env: _noPromptEnv()
});
return String(stdout);
} catch (e) {
const err = (e.stderr || e.stdout || e.message || '').toString().trim();
const safeArgs = args.join(' ');
throw new Error(`git ${safeArgs} failed: ${err.split('\n').slice(-3).join(' ').slice(0, 400)}`);
}
}
async function _gitTryAsync(args, cwd, opts = {}) {
try {
return { ok: true, out: (await _gitAsync(args, cwd, opts)).trim(), err: '' };
} catch (e) {
return { ok: false, out: '', err: (e.message || '').toString() };
}
}
function _isRepo(dir) {
try { return fs.existsSync(path.join(dir, '.git')); } catch { return false; }
}
// Find the worktree (if any) that currently has local branch `br` checked out
// (non-detached) in this clone. Git allows a given local branch to be checked
// out in only ONE worktree at a time; a second `git worktree add <dir> <br>`
// dies with "fatal: '<br>' is already checked out at '<other>'". Dev cards and
// PR stewards can legitimately want the SAME source branch checked out (both
// need to commit to it), so we detect the existing checkout and SHARE it rather
// than let the add fail. Returns the absolute worktree path, or ''.
function _branchWorktree(clone, br) {
const r = _gitTry(['worktree', 'list', '--porcelain'], clone);
if (!r.ok || !r.out) return '';
let cur = '';
const want = 'refs/heads/' + br;
for (const raw of r.out.split('\n')) {
const line = raw.trim();
if (line.startsWith('worktree ')) { cur = line.slice(9).trim(); continue; }
if (line === 'branch ' + want || line === 'branch ' + br) return cur;
}
return '';
}
// Free local branch `br` from any FOREIGN worktree that currently has it checked
// out (i.e. a worktree other than `keepDir`). Under the current model a real
// branch lives in exactly one place: dev/<slug> in the dev card's own worktree,
// pr/<slug>-N in that PR's own worktree. Anything else holding it is a review
// checkout that should have been detached (legacy non-detached cfpr dirs squat
// the branch and block the rightful owner from `git worktree add`). We detach
// those in place — the working tree stays at the same commit, HEAD just goes
// detached — releasing the branch name. `git worktree add` can then reclaim it.
// Returns the list of dirs we freed (for logging). Never throws.
function _freeBranchFromForeignWorktrees(clone, br, keepDir) {
const freed = [];
const r = _gitTry(['worktree', 'list', '--porcelain'], clone);
if (!r.ok || !r.out) return freed;
const want = 'refs/heads/' + br;
const keep = path.resolve(keepDir || '');
let cur = '';
const holders = [];
for (const raw of r.out.split('\n')) {
const line = raw.trim();
if (line.startsWith('worktree ')) { cur = line.slice(9).trim(); continue; }
if ((line === 'branch ' + want || line === 'branch ' + br) && cur) holders.push(cur);
}
for (const dir of holders) {
if (keep && path.resolve(dir) === keep) continue; // the rightful owner — leave it
// Detach HEAD in place at the current commit so the branch name is released
// but the checked-out tree (and any review artifacts) survive.
if (_gitTry(['checkout', '--detach'], dir).ok || _gitTry(['switch', '--detach'], dir).ok) {
freed.push(dir);
}
}
if (freed.length) _gitTry(['worktree', 'prune'], clone);
return freed;
}
// Add a worktree for local branch `br`, first freeing it from any foreign
// worktree that squats it (see _freeBranchFromForeignWorktrees). On the initial
// add failing with "already used by worktree", free + retry once. Throws (via
// _git) only if the retry also fails.
function _addBranchWorktreeReclaiming(clone, wt, br) {
_freeBranchFromForeignWorktrees(clone, br, wt);
const first = _gitTry(['worktree', 'add', wt, br], clone);
if (first.ok) return;
if (/already used by worktree|already checked out/i.test(first.err)) {
_freeBranchFromForeignWorktrees(clone, br, wt);
_git(['worktree', 'add', wt, br], clone); // throw with a clean message if still stuck
return;
}
throw new Error(first.err);
}
// Ensure the managed clone exists and is fetched. Returns the clone path.
// `desc` (optional) carries {provider, org/owner, project, repo}; when absent
// the record is treated as Azure DevOps (back-compat with positional callers).
function ensureClone(org, project, repo, desc) {
const provider = String((desc && desc.provider) || 'azdo').toLowerCase();
const auth = desc || true;
const dir = clonePath(org, project, repo, provider);
if (_isRepo(dir)) {
_gitTry(['fetch', '--prune', 'origin'], dir, { auth });
return dir;
}
fs.mkdirSync(path.dirname(dir), { recursive: true });
// Both providers expose cloneUrl(org/owner, project, repo) (GitHub ignores project).
const url = forge(desc).cloneUrl(org, project, repo);
_git(['clone', url, dir], path.dirname(dir), { auth });
return dir;
}
// Resolve a usable base ref in `clone`, falling back to the remote's advertised default
// branch when the requested base can't be found. Guards the "dev card repo was changed"
// case: the old base branch (seeded from the previous repo) won't exist on the new
// remote, and blindly checking out a non-existent base fails with "invalid reference".
// Returns a resolvable ref string (e.g. 'origin/main') or null when nothing resolves.
function _resolveBaseRef(clone, base) {
const want = (base || '').trim();
if (want) {
if (_gitTry(['rev-parse', '--verify', '--quiet', 'origin/' + want], clone).ok) return 'origin/' + want;
if (_gitTry(['rev-parse', '--verify', '--quiet', want], clone).ok) return want;
}
// Remote's advertised default branch (origin/HEAD → e.g. refs/remotes/origin/main).
const sym = _gitTry(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], clone);
if (sym.ok && sym.out) {
const ref = sym.out.trim().replace(/^refs\/remotes\//, '');
if (ref && _gitTry(['rev-parse', '--verify', '--quiet', ref], clone).ok) return ref;
}
for (const cand of ['origin/main', 'origin/master']) {
if (_gitTry(['rev-parse', '--verify', '--quiet', cand], clone).ok) return cand;
}
return null;
}
// Create (or reuse) a worktree for a Dev item. Returns { worktreePath, branch, reused }.
function createWorktree({ org, project, repo, baseBranch, branch, devId, detach, provider }) {
const desc = provider ? { provider, org, project, repo } : null;
const clone = ensureClone(org, project, repo, desc);
const base = (baseBranch || '').trim() || 'main';
const br = (branch || '').trim() || ('dev/' + _safe(devId));
const wt = worktreePath(repo, devId);
if (_isRepo(wt)) {
return { worktreePath: wt, branch: br, reused: true };
}
fs.mkdirSync(path.dirname(wt), { recursive: true });
// Clear any stale worktree registrations (e.g. a prior worktree dir that was deleted
// out from under git) so re-adding a branch whose old worktree is gone won't fail with
// "already checked out" / "missing but locked".
_gitTry(['worktree', 'prune'], clone);
// Detached/read-only review checkout: snapshot the branch tip without occupying the
// branch name, so it never collides with another worktree (e.g. a dev card) that has
// the same branch checked out. Used for PR review worktrees.
if (detach) {
const ref = _gitTry(['rev-parse', '--verify', '--quiet', 'origin/' + br], clone).ok
? 'origin/' + br
: (_gitTry(['rev-parse', '--verify', '--quiet', 'refs/heads/' + br], clone).ok
? br
: (_gitTry(['rev-parse', '--verify', '--quiet', 'origin/' + base], clone).ok ? 'origin/' + base : base));
_git(['worktree', 'add', '--detach', wt, ref], clone);
return { worktreePath: wt, branch: br, reused: false, detached: true };
}
// Does the branch already exist locally in the managed clone? This happens when a
// worktree was created before and later removed (remove-worktree deletes the worktree
// dir but leaves the local branch behind). Attach the existing branch instead of
// trying to (re-)create it, which would fail with "a branch named '…' already exists".
const localHas = _gitTry(['rev-parse', '--verify', '--quiet', 'refs/heads/' + br], clone).ok;
// Does the branch already exist on origin?
const remoteHas = _gitTry(['rev-parse', '--verify', '--quiet', 'origin/' + br], clone).ok;
// NOTE: dev cards own `dev/<slug>` and PRs own `pr/<slug>-<N>` — distinct branch
// namespaces, each in its OWN worktree dir. A local branch is therefore only ever
// checked out in ONE worktree, so the old dev↔PR "already checked out → SHARE it"
// collision guard is no longer needed (and was itself the source of the dev card
// borrowing the PR's worktree). It has been removed intentionally.
if (localHas) {
// Check out the pre-existing local branch into the new worktree (no -b/-B so we
// don't discard any local commits the branch already carries). If a foreign
// worktree (e.g. a legacy non-detached PR-review checkout) is squatting the
// branch, free it first and reclaim — this is the fix for dev cards that could
// never (re)create their own worktree because a review dir held dev/<slug>.
_addBranchWorktreeReclaiming(clone, wt, br);
} else if (remoteHas) {
// Track the existing remote branch.
_git(['worktree', 'add', '--track', '-B', br, wt, 'origin/' + br], clone);
} else {
// Brand-new branch off the base; no upstream yet (status compares vs origin/base).
// Resolve the base robustly: the requested base may not exist on this remote (e.g.
// the card's repo was changed and the old base doesn't exist on the new repo), so
// fall back to the remote's default branch instead of failing with "invalid
// reference". Only throw when the remote has no resolvable base at all.
const baseRef = _resolveBaseRef(clone, base);
if (!baseRef) throw new Error('Cannot resolve a base branch for ' + repo + ' (tried "' + base + '" and the remote default).');
_git(['worktree', 'add', '-b', br, wt, baseRef], clone);
}
return { worktreePath: wt, branch: br, reused: false };
}
// Create a PR's OWN attached, committable worktree on branch `prBranch`, seeded from
// `fromRef` (typically the dev card's `dev/<slug>` branch tip). The worktree dir is
// keyed by a synthetic devId (e.g. `<devId>--pr<N>`) so it can NEVER be the same dir
// as the dev card's own worktree, and the `pr/…` namespace means it can never be the
// same branch either. Different branch AND different dir ⇒ the old dev↔PR worktree
// collision is structurally impossible. Idempotent: reuses the dir if it already
// exists. Returns { worktreePath, branch, reused }.
function createPrWorktree({ org, project, repo, provider, prBranch, fromRef, wtDevId }) {
const desc = provider ? { provider, org, project, repo } : null;
const clone = ensureClone(org, project, repo, desc);
const br = String(prBranch || '').trim();
if (!br) throw new Error('createPrWorktree: prBranch is required');
const wt = worktreePath(repo, wtDevId);
if (_isRepo(wt)) return { worktreePath: wt, branch: br, reused: true };
fs.mkdirSync(path.dirname(wt), { recursive: true });
_gitTry(['worktree', 'prune'], clone);
const localHas = _gitTry(['rev-parse', '--verify', '--quiet', 'refs/heads/' + br], clone).ok;
if (localHas) {
// Branch already exists (e.g. a re-open after the worktree dir was removed) — attach it,
// freeing it from any foreign worktree squatting it first.
_addBranchWorktreeReclaiming(clone, wt, br);
} else {
// Seed the new PR branch at the dev tip. `fromRef` is a local ref (the dev branch)
// whose commit is a real object in this shared clone, so branching from it works
// even though that branch is checked out in the dev worktree (we branch, not check out).
const seed = String(fromRef || '').trim() || 'HEAD';
_git(['worktree', 'add', '-b', br, wt, seed], clone);
}
return { worktreePath: wt, branch: br, reused: false };
}
// Divergence between a dev card's private working branch (dev/<slug>) and its
// published PR branch (pr/<slug>-<N>). Both are LOCAL refs in the same shared
// clone, so this is a pure local rev-list — no network, no worktree, no fetch.
// promote = commits on dev not yet on the PR branch (dev -> pr, "to promote")
// pull = commits on the PR branch not yet on dev (pr -> dev, "to pull back")
// A non-zero `pull` means the PR branch moved on its own (review-time commits,
// a promote from a sibling, a squash) and the dev branch is behind it. Returns
// { promote, pull, comparable } — comparable=false when either ref is missing
// (e.g. no PR yet, or the clone hasn't been created). Never throws.
function devPrDivergence({ org, project, repo, provider = 'azdo', devBranch, prBranch } = {}) {
const zero = { promote: 0, pull: 0, comparable: false };
const dev = String(devBranch || '').replace(/^refs\/heads\//, '').trim();
const pr = String(prBranch || '').replace(/^refs\/heads\//, '').trim();
if (!dev || !pr) return zero;
const clone = clonePath(org, project, repo, String(provider || 'azdo').toLowerCase());
if (!_isRepo(clone)) return zero;
const devRef = 'refs/heads/' + dev;
const prRef = 'refs/heads/' + pr;
if (!_gitTry(['rev-parse', '--verify', '--quiet', devRef], clone).ok) return zero;
if (!_gitTry(['rev-parse', '--verify', '--quiet', prRef], clone).ok) return zero;
// `--left-right --count A...B`: left = reachable from A not B, right = from B not A.
// A=prRef, B=devRef => left = on pr not dev (pull), right = on dev not pr (promote).
const counts = _gitTry(['rev-list', '--left-right', '--count', prRef + '...' + devRef], clone);
if (!counts.ok) return zero;
const m = counts.out.split(/\s+/);
const pull = parseInt(m[0], 10) || 0;
const promote = parseInt(m[1], 10) || 0;
return { promote, pull, comparable: true };
}
// Promote a dev card's private work onto its PUBLISHED PR branch (dev -> pr) and
// push it so the open PR picks up the new commits. Runs inside the PR's OWN
// worktree (never the dev worktree — dev/<slug> and pr/<slug>-<N> are on distinct
// branches in distinct dirs, so this never fights the dev checkout). Fast-forwards
// when the PR branch is a strict ancestor of dev (the common case: PR seeded at the
// dev tip, dev advanced, PR untouched); otherwise creates a merge commit so
// review-time commits on the PR branch are preserved. `desc` carries provider auth
// for the push. Returns { ok, promoted, pushed, message }. Never throws.
function promoteToPr(prWt, { devBranch, prBranch, desc = null } = {}) {
if (!prWt || !_isRepo(prWt)) return { ok: false, promoted: 0, pushed: false, message: 'PR worktree is missing — create the PR first.' };
const dev = String(devBranch || '').replace(/^refs\/heads\//, '').trim();
const pr = String(prBranch || '').replace(/^refs\/heads\//, '').trim();
if (!dev || !pr) return { ok: false, promoted: 0, pushed: false, message: 'devBranch and prBranch are required.' };
const devRef = 'refs/heads/' + dev;
if (!_gitTry(['rev-parse', '--verify', '--quiet', devRef], prWt).ok) return { ok: false, promoted: 0, pushed: false, message: 'Dev branch ' + dev + ' not found.' };
// Make sure the PR worktree is actually on the PR branch before we merge into it.
const cur = (_gitTry(['rev-parse', '--abbrev-ref', 'HEAD'], prWt).out || '').trim();
if (cur !== pr) {
const co = _gitTry(['checkout', pr], prWt);
if (!co.ok) return { ok: false, promoted: 0, pushed: false, message: 'Could not switch the PR worktree to ' + pr + ': ' + co.err.split('\n').slice(-2).join(' ').slice(0, 200) };
}
const cnt = _gitTry(['rev-list', '--count', pr + '..' + dev], prWt);
const n = cnt.ok ? (parseInt(cnt.out, 10) || 0) : 0;
if (n === 0) return { ok: true, promoted: 0, pushed: false, message: 'The PR is already up to date with dev.' };
_ensureGitIdentity(prWt);
let merged = _gitTry(['merge', '--ff-only', devRef], prWt);
if (!merged.ok) merged = _gitTry(['merge', '--no-edit', devRef], prWt);
if (!merged.ok) {
// Collect the conflicting paths, then abort so the PR worktree is left clean
// (not mid-conflict). The caller surfaces { conflict, files } so the UI can
// offer the merge-steward playbook instead of a raw failure.
let files = [];
try {
const u = _gitTry(['diff', '--name-only', '--diff-filter=U'], prWt);
files = (u.out || '').split('\n').map(s => s.trim()).filter(Boolean);
} catch {}
_gitTry(['merge', '--abort'], prWt);
const isConflict = files.length > 0 || /conflict/i.test(merged.err || '');
return {
ok: false, promoted: n, pushed: false, conflict: isConflict, files,
message: isConflict
? 'Promoting ' + dev + ' into ' + pr + ' hit ' + (files.length ? files.length + ' conflicting file' + (files.length === 1 ? '' : 's') : 'conflicts') + '. A merge steward can resolve them safely.'
: 'Could not promote (merge failed): ' + merged.err.split('\n').slice(-2).join(' ').slice(0, 260)
};
}
const push = _gitTry(['push', 'origin', pr], prWt, { auth: desc || true });
if (!push.ok) return { ok: false, promoted: n, pushed: false, message: 'Promoted locally but the push failed: ' + push.err.split('\n').slice(-2).join(' ').slice(0, 260) };
return { ok: true, promoted: n, pushed: true, message: 'Promoted ' + n + ' commit' + (n === 1 ? '' : 's') + ' to the PR.' };
}
// Pull review-time commits from the PUBLISHED PR branch back into the dev card's
// private working branch (pr -> dev). Runs inside the DEV worktree. The dev branch
// is never pushed, so this is a local merge only. Fast-forwards when dev is a strict
// ancestor of the PR branch; otherwise a merge commit. Returns
// { ok, pulled, message }. Never throws.
function pullFromPr(devWt, { devBranch, prBranch } = {}) {
if (!devWt || !_isRepo(devWt)) return { ok: false, pulled: 0, message: 'Dev worktree is missing.' };
const dev = String(devBranch || '').replace(/^refs\/heads\//, '').trim();
const pr = String(prBranch || '').replace(/^refs\/heads\//, '').trim();
if (!dev || !pr) return { ok: false, pulled: 0, message: 'devBranch and prBranch are required.' };
const prRef = 'refs/heads/' + pr;
if (!_gitTry(['rev-parse', '--verify', '--quiet', prRef], devWt).ok) return { ok: false, pulled: 0, message: 'PR branch ' + pr + ' not found.' };
const cur = (_gitTry(['rev-parse', '--abbrev-ref', 'HEAD'], devWt).out || '').trim();
if (cur !== dev) {
const co = _gitTry(['checkout', dev], devWt);
if (!co.ok) return { ok: false, pulled: 0, message: 'Could not switch the dev worktree to ' + dev + ': ' + co.err.split('\n').slice(-2).join(' ').slice(0, 200) };
}
const cnt = _gitTry(['rev-list', '--count', dev + '..' + pr], devWt);
const n = cnt.ok ? (parseInt(cnt.out, 10) || 0) : 0;
if (n === 0) return { ok: true, pulled: 0, message: 'Dev is already up to date with the PR.' };
_ensureGitIdentity(devWt);
let merged = _gitTry(['merge', '--ff-only', prRef], devWt);
if (!merged.ok) merged = _gitTry(['merge', '--no-edit', prRef], devWt);
if (!merged.ok) return { ok: false, pulled: n, message: 'Could not pull (merge failed — resolve conflicts in the dev worktree): ' + merged.err.split('\n').slice(-2).join(' ').slice(0, 260) };
return { ok: true, pulled: n, message: 'Pulled ' + n + ' review commit' + (n === 1 ? '' : 's') + ' into dev.' };
}
// Merge one aspect's branch INTO another aspect's branch, running inside the
// TARGET aspect's worktree. Both branches are local branches of the same shared
// clone (every aspect is a worktree of it), so this is a local merge only — the
// source branch is referenced as a ref and never checked out here. Nothing is
// pushed; the caller decides whether to promote/push afterwards (e.g. when the
// target aspect owns the repo PR). Fast-forwards when possible, else a merge
// commit. Returns { ok, merged, message }. Never throws.
function mergeAspectBranch(targetWt, { sourceBranch, targetBranch, desc = null } = {}) {
if (!targetWt || !_isRepo(targetWt)) return { ok: false, merged: 0, message: 'Target aspect worktree is missing.' };
const src = String(sourceBranch || '').replace(/^refs\/heads\//, '').trim();
const tgt = String(targetBranch || '').replace(/^refs\/heads\//, '').trim();
if (!src || !tgt) return { ok: false, merged: 0, message: 'sourceBranch and targetBranch are required.' };
if (src === tgt) return { ok: false, merged: 0, message: 'An aspect cannot merge into itself.' };
const srcRef = 'refs/heads/' + src;
if (!_gitTry(['rev-parse', '--verify', '--quiet', srcRef], targetWt).ok) return { ok: false, merged: 0, message: 'Source aspect branch ' + src + ' not found.' };
// Ensure the target worktree is actually on the target branch before merging.
const cur = (_gitTry(['rev-parse', '--abbrev-ref', 'HEAD'], targetWt).out || '').trim();
if (cur !== tgt) {
const co = _gitTry(['checkout', tgt], targetWt);
if (!co.ok) return { ok: false, merged: 0, message: 'Could not switch the target aspect worktree to ' + tgt + ': ' + co.err.split('\n').slice(-2).join(' ').slice(0, 200) };
}
const cnt = _gitTry(['rev-list', '--count', tgt + '..' + src], targetWt);
const n = cnt.ok ? (parseInt(cnt.out, 10) || 0) : 0;
if (n === 0) return { ok: true, merged: 0, message: 'This aspect already contains everything from ' + src + '.' };
_ensureGitIdentity(targetWt);
let merged = _gitTry(['merge', '--ff-only', srcRef], targetWt);
if (!merged.ok) merged = _gitTry(['merge', '--no-edit', srcRef], targetWt);
if (!merged.ok) {
// Abort a half-applied merge so the worktree is left clean, not mid-conflict.
_gitTry(['merge', '--abort'], targetWt);
return { ok: false, merged: n, conflict: true, message: 'Merge failed — resolve conflicts in the target aspect worktree: ' + merged.err.split('\n').slice(-2).join(' ').slice(0, 260) };
}
return { ok: true, merged: n, message: 'Merged ' + n + ' commit' + (n === 1 ? '' : 's') + ' from ' + src + '.' };
}
// Compute ahead/behind/dirty for a worktree. Optionally fetch first.
// `desc` (optional) is a provider descriptor ({provider, org/owner, project, repo});
// when present its host-scoped auth is used for the remote fetch (GitHub HTTP-basic
// vs AzDO bearer). Omitted ⇒ auth:true ⇒ the AzDO default (byte-identical, no change).
function worktreeStatus(wt, { fetch = true, baseBranch = 'main', desc = null } = {}) {
if (!wt || !_isRepo(wt)) return null;
if (fetch) _gitTry(['fetch', '--prune', 'origin'], wt, { auth: desc || true });
const branch = _gitTry(['rev-parse', '--abbrev-ref', 'HEAD'], wt).out || '';
const up = _gitTry(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], wt);
const compare = up.ok && up.out ? '@{u}' : ('origin/' + (baseBranch || 'main'));
let ahead = 0, behind = 0, comparable = false;
const counts = _gitTry(['rev-list', '--left-right', '--count', compare + '...HEAD'], wt);
if (counts.ok) {
const m = counts.out.split(/\s+/);
behind = parseInt(m[0], 10) || 0;
ahead = parseInt(m[1], 10) || 0;
comparable = true;
}
// Exclude agent-generated status reports (IMPLEMENTATION_SUMMARY.md, etc.) from
// the dirty signal — they're surfaced on the card but intentionally never
// committed, so they must not light up the worktree as "dirty".
const cls = classifyPorcelain(_gitTry(['status', '--porcelain', '-uall'], wt).out || '');
const dirty = cls.dirty;
// HEAD commit sha — lets callers detect a new commit even when ahead/behind
// don't move (e.g. an amend, or a commit that also pulled base in).
const head = _gitTry(['rev-parse', 'HEAD'], wt).out || '';
return {
branch, head,
upstream: up.ok ? up.out : '',
tracking: compare === '@{u}' ? (up.out || '') : compare,
ahead, behind, comparable, dirty,
ignoredReports: cls.ignored,
lastChecked: new Date().toISOString()
};
}
// ASYNC twin of worktreeStatus — identical result shape, but every git call is
// non-blocking (via _gitTryAsync) so the background reconciler never wedges the
// event loop on a locked/slow worktree. `timeout` caps each local git op (a
// stuck one rejects instead of hanging). Only the reconciler needs this; HTTP
// handlers keep using the synchronous worktreeStatus.
async function worktreeStatusAsync(wt, { fetch = false, baseBranch = 'main', desc = null, timeout = 20_000 } = {}) {
if (!wt || !_isRepo(wt)) return null;
const o = { timeout };
if (fetch) await _gitTryAsync(['fetch', '--prune', 'origin'], wt, { auth: desc || true, timeout: Math.max(timeout, 60_000) });
const branch = (await _gitTryAsync(['rev-parse', '--abbrev-ref', 'HEAD'], wt, o)).out || '';
const up = await _gitTryAsync(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], wt, o);
const compare = up.ok && up.out ? '@{u}' : ('origin/' + (baseBranch || 'main'));
let ahead = 0, behind = 0, comparable = false;
const counts = await _gitTryAsync(['rev-list', '--left-right', '--count', compare + '...HEAD'], wt, o);
if (counts.ok) {
const m = counts.out.split(/\s+/);
behind = parseInt(m[0], 10) || 0;
ahead = parseInt(m[1], 10) || 0;
comparable = true;
}
const cls = classifyPorcelain((await _gitTryAsync(['status', '--porcelain', '-uall'], wt, o)).out || '');
const dirty = cls.dirty;
const head = (await _gitTryAsync(['rev-parse', 'HEAD'], wt, o)).out || '';
return {
branch, head,
upstream: up.ok ? up.out : '',
tracking: compare === '@{u}' ? (up.out || '') : compare,
ahead, behind, comparable, dirty,
ignoredReports: cls.ignored,
lastChecked: new Date().toISOString()
};
}
// Detailed commit-level view of a worktree branch vs its tracked remote — the
// branch's own origin/<branch> when an upstream exists, else origin/<baseBranch>.
// Lets the UI show exactly which commits are local-only (unpushed), which exist
// on the remote but are missing locally (behind), and a recent-history list with
// each commit tagged pushed/local. For a branch with an open PR the upstream IS
// the PR's source branch, so this doubles as "local worktree vs PR branch".
// Never throws; returns null when the path is not a repo.
function branchCommits(wt, { baseBranch = 'main', limit = 40, fetch = false, desc = null } = {}) {
if (!wt || !_isRepo(wt)) return null;
if (fetch) _gitTry(['fetch', '--prune', 'origin'], wt, { auth: desc || true });
const branch = _gitTry(['rev-parse', '--abbrev-ref', 'HEAD'], wt).out || '';
const up = _gitTry(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], wt);
const hasUpstream = up.ok && !!up.out;
const tracking = hasUpstream ? up.out : ('origin/' + (baseBranch || 'main'));
const trackingExists = _gitTry(['rev-parse', '--verify', '--quiet', tracking], wt).ok;
const SEP = '\x1f';
const FMT = ['%H', '%h', '%s', '%an', '%aI'].join(SEP);
const parse = (out) => String(out || '').split('\n').filter(Boolean).map((l) => {
const [sha, short, subject, author, date] = l.split(SEP);
return { sha, short, subject, author, date };
});
let ahead = [], behind = [];
if (trackingExists) {
ahead = parse(_gitTry(['log', tracking + '..HEAD', '--pretty=format:' + FMT, '-n', String(limit)], wt).out);
behind = parse(_gitTry(['log', 'HEAD..' + tracking, '--pretty=format:' + FMT, '-n', String(limit)], wt).out);
}
const recent = parse(_gitTry(['log', 'HEAD', '--pretty=format:' + FMT, '-n', String(limit)], wt).out);
const aheadSet = new Set(ahead.map((c) => c.sha));
for (const c of recent) c.pushed = trackingExists ? !aheadSet.has(c.sha) : false;
return {
branch, tracking, hasUpstream, trackingExists,
ahead, behind, recent,
aheadCount: ahead.length, behindCount: behind.length,
truncated: recent.length >= limit,
lastChecked: new Date().toISOString()
};
}
// Fetch + fast-forward the worktree. Returns { ok, message, status }.
function syncWorktree(wt, { baseBranch = 'main', desc = null } = {}) {
if (!wt || !_isRepo(wt)) return { ok: false, message: 'No worktree to sync.' };
_gitTry(['fetch', '--prune', 'origin'], wt, { auth: desc || true });
const up = _gitTry(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], wt);
let res;
if (up.ok && up.out) {
res = _gitTry(['pull', '--ff-only'], wt, { auth: desc || true });
} else {
// No upstream: fast-forward onto the base branch instead.
res = _gitTry(['merge', '--ff-only', 'origin/' + (baseBranch || 'main')], wt);
}
const status = worktreeStatus(wt, { fetch: false, baseBranch, desc });
if (!res.ok) {
const diverged = /non-fast-forward|not possible to fast-forward|diverging|diverge/i.test(res.err);
return {
ok: false,
message: diverged
? 'Branches have diverged — a fast-forward sync is not possible. Resolve locally (rebase/merge).'
: (res.err.split('\n').slice(-2).join(' ').slice(0, 300) || 'Sync failed.'),
status
};
}
return { ok: true, message: 'Up to date with origin.', status };
}
// Prepare a detached PR-review checkout for an AI run. Clean snapshots are
// advanced to the forge-reported PR head; worktrees with real local changes are
// never reset. In that case the caller can still produce a blocked diagnostic
// report rather than reviewing or modifying the wrong revision.
function prepareReviewWorktree(wt, { sourceBranch = '', expectedHead = '', desc = null } = {}) {
if (!wt || !_isRepo(wt)) return { ok: false, state: 'missing', message: 'Review worktree is unavailable.' };
const src = String(sourceBranch || '').replace(/^refs\/heads\//, '').trim();
const expected = String(expectedHead || '').trim().toLowerCase();
if (src) _gitTry(['fetch', '--prune', 'origin', src], wt, { auth: desc || true });
if (expected && !_gitTry(['cat-file', '-e', expected + '^{commit}'], wt).ok) {
_gitTry(['fetch', 'origin', expected], wt, { auth: desc || true });
}
const remoteHead = expected ||
(_gitTry(['rev-parse', '--verify', '--quiet', 'origin/' + src], wt).out || '').trim().toLowerCase();
const localHead = (_gitTry(['rev-parse', 'HEAD'], wt).out || '').trim().toLowerCase();
if (!remoteHead) return { ok: false, state: 'head-unavailable', localHead, message: 'Could not resolve the current PR head.' };
if (localHead === remoteHead) return { ok: true, state: 'current', localHead, remoteHead, message: 'Review checkout matches the current PR head.' };
const changes = classifyPorcelain(_gitTry(['status', '--porcelain', '-uall'], wt).out || '');
if (changes.dirty) {
return {
ok: false, state: 'local-changes', localHead, remoteHead, changed: changes.changed.slice(0, 20),
message: 'Review checkout differs from the current PR head and contains local changes; it was preserved.'
};
}
const moved = _gitTry(['checkout', '--detach', '--force', remoteHead], wt);
const after = (_gitTry(['rev-parse', 'HEAD'], wt).out || '').trim().toLowerCase();
if (!moved.ok || after !== remoteHead) {
return {
ok: false, state: 'refresh-failed', localHead, remoteHead,
message: (moved.err || 'Could not advance the review checkout to the current PR head.').split('\n').slice(-2).join(' ').slice(0, 400)
};
}
return { ok: true, state: 'refreshed', localHead: after, previousHead: localHead, remoteHead, message: 'Review checkout refreshed to the current PR head.' };
}
// Truncate a patch to a character budget on a line boundary, with a marker.
function _capPatch(text, maxChars) {
const s = String(text || '');
if (s.length <= maxChars) return s;
const cut = s.slice(0, maxChars);
const nl = cut.lastIndexOf('\n');
return (nl > 0 ? cut.slice(0, nl) : cut) +
'\n… [diff truncated, ' + (s.length - maxChars) + ' more chars]';
}
// Pathspecs for noisy/generated files we never want to spend the diff budget on.
const _DIFF_EXCLUDES = [
':(exclude)**/package-lock.json',
':(exclude)**/yarn.lock',
':(exclude)**/pnpm-lock.yaml',
':(exclude)**/*.min.js',
':(exclude)**/*.map',
':(exclude)**/dist/**',
':(exclude)**/build/**'
];
// A textual summary of the code state for the AI summary prompt. Includes the
// actual patch hunks (committed-vs-base, staged, and unstaged) so the model can
// reason about WHAT changed, not just which files — each section bounded by a
// character budget so the overall prompt stays manageable.
function diffSummary(wt, { baseBranch = 'main', maxLines = 40, maxDiffChars = 9000 } = {}) {
if (!wt || !_isRepo(wt)) return '';
const base = 'origin/' + (baseBranch || 'main');
const out = [];
// High-level overview: which files changed vs base + how much churn.
const stat = _gitTry(['diff', '--stat', base + '...HEAD'], wt);
if (stat.ok && stat.out) {
out.push('Changed files (git diff --stat vs ' + base + '):');
out.push(stat.out.split('\n').slice(0, maxLines).join('\n'));
}
// Commits unique to this branch.
const log = _gitTry(['log', '--oneline', '-15', base + '..HEAD'], wt);
if (log.ok && log.out) {
out.push('');
out.push('Recent commits on this branch:');
out.push(log.out);
}
// The actual committed change set vs base — the primary "what changed" patch.
const committed = _gitTry(['diff', '--unified=3', base + '...HEAD', '--'].concat(_DIFF_EXCLUDES), wt);
if (committed.ok && committed.out && committed.out.trim()) {
out.push('');
out.push('Committed changes vs ' + base + ' (patch):');
out.push(_capPatch(committed.out, maxDiffChars));
}
// In-progress edits, split into staged vs unstaged so the model can tell them apart.
const staged = _gitTry(['diff', '--staged', '--unified=3', '--'].concat(_DIFF_EXCLUDES), wt);
if (staged.ok && staged.out && staged.out.trim()) {
out.push('');
out.push('Staged (not yet committed) changes (patch):');
out.push(_capPatch(staged.out, Math.floor(maxDiffChars / 2)));
}
const unstaged = _gitTry(['diff', '--unified=3', '--'].concat(_DIFF_EXCLUDES), wt);
if (unstaged.ok && unstaged.out && unstaged.out.trim()) {
out.push('');
out.push('Unstaged working changes (patch):');
out.push(_capPatch(unstaged.out, Math.floor(maxDiffChars / 2)));
}
// Porcelain status catches untracked files (and anything excluded above).
const dirty = _gitTry(['status', '--porcelain'], wt);
if (dirty.ok && dirty.out) {
out.push('');
out.push('Working tree status (git status --porcelain):');
out.push(dirty.out.split('\n').slice(0, maxLines).join('\n'));
}
return out.join('\n').trim();
}
// ASYNC twin of diffSummary — same prose output, non-blocking git so the
// reconciler's (throttled) summary regeneration can't freeze the loop either.
async function diffSummaryAsync(wt, { baseBranch = 'main', maxLines = 40, maxDiffChars = 9000, timeout = 25_000 } = {}) {
if (!wt || !_isRepo(wt)) return '';
const base = 'origin/' + (baseBranch || 'main');
const o = { timeout };
const out = [];
const stat = await _gitTryAsync(['diff', '--stat', base + '...HEAD'], wt, o);
if (stat.ok && stat.out) {
out.push('Changed files (git diff --stat vs ' + base + '):');
out.push(stat.out.split('\n').slice(0, maxLines).join('\n'));
}
const log = await _gitTryAsync(['log', '--oneline', '-15', base + '..HEAD'], wt, o);
if (log.ok && log.out) {
out.push('');
out.push('Recent commits on this branch:');
out.push(log.out);
}
const committed = await _gitTryAsync(['diff', '--unified=3', base + '...HEAD', '--'].concat(_DIFF_EXCLUDES), wt, o);
if (committed.ok && committed.out && committed.out.trim()) {
out.push('');
out.push('Committed changes vs ' + base + ' (patch):');
out.push(_capPatch(committed.out, maxDiffChars));
}
const staged = await _gitTryAsync(['diff', '--staged', '--unified=3', '--'].concat(_DIFF_EXCLUDES), wt, o);
if (staged.ok && staged.out && staged.out.trim()) {
out.push('');
out.push('Staged (not yet committed) changes (patch):');
out.push(_capPatch(staged.out, Math.floor(maxDiffChars / 2)));
}
const unstaged = await _gitTryAsync(['diff', '--unified=3', '--'].concat(_DIFF_EXCLUDES), wt, o);
if (unstaged.ok && unstaged.out && unstaged.out.trim()) {
out.push('');
out.push('Unstaged working changes (patch):');
out.push(_capPatch(unstaged.out, Math.floor(maxDiffChars / 2)));
}
const dirty = await _gitTryAsync(['status', '--porcelain'], wt, o);
if (dirty.ok && dirty.out) {
out.push('');
out.push('Working tree status (git status --porcelain):');
out.push(dirty.out.split('\n').slice(0, maxLines).join('\n'));
}
return out.join('\n').trim();
}
// Returns { ok, branch, message }.
function pushBranch(wt, { branch, desc = null } = {}) {
if (!wt || !_isRepo(wt)) return { ok: false, branch: '', message: 'No worktree to push.' };
let br = String(branch || '').trim();
if (!br) {
const cur = _gitTry(['rev-parse', '--abbrev-ref', 'HEAD'], wt);
br = cur.ok ? cur.out.trim() : '';
}
if (!br || br === 'HEAD') return { ok: false, branch: '', message: 'Could not determine the branch to push.' };
const res = _gitTry(['push', '-u', 'origin', br], wt, { auth: desc || true });
return {
ok: res.ok,
branch: br,
message: res.ok ? 'Pushed.' : (res.err.split('\n').slice(-2).join(' ').slice(0, 300) || 'Push failed.')
};
}
// Ensure the worktree has a committer identity so `git commit` won't fail on a
// machine with no global user.name/user.email. Sets a repo-local identity only
// when one is missing. Best-effort.
function _ensureGitIdentity(wt) {
const name = _gitTry(['config', 'user.name'], wt);
if (!name.ok || !name.out) _gitTry(['config', 'user.name', 'TheOffice.AI'], wt);
const email = _gitTry(['config', 'user.email'], wt);
if (!email.ok || !email.out) _gitTry(['config', 'user.email', 'noreply@theoffice.ai'], wt);
}
// Stage every change in the worktree and commit it. Used to make sure a user's
// uncommitted local edits are captured before a push / PR. Returns
// { ok, committed, files, message }. committed=false (ok:true) when nothing was
// staged (clean tree) — that is not an error.
// List a worktree's uncommitted changes, split into committable `changed` and
// ignorable agent-report `ignored`, each entry `{ path, xy }` where xy is the
// 2-char git porcelain status (e.g. " M", "??", "A "). Never throws.
function worktreeChanges(wt) {
if (!wt || !_isRepo(wt)) return { dirty: false, changed: [], ignored: [] };
// NB: parse the UNTRIMMED porcelain output. `git status --porcelain` is
// column-aligned (2 status chars + space + path); a worktree-only change like
// " M path" starts with a space, and _gitTry's global .trim() would eat that
// leading space, shifting every slice by one and corrupting the first path.
let out = '';
try { out = _git(['status', '--porcelain', '-uall'], wt); } catch (_) { return { dirty: false, changed: [], ignored: [] }; }
const changed = [], ignored = [];
for (const raw of out.split('\n')) {
if (!raw.trim()) continue;
const xy = raw.slice(0, 2);
let p = raw.slice(3).trim();
const arrow = p.indexOf(' -> ');
if (arrow >= 0) p = p.slice(arrow + 4).trim();
if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
(isIgnorableWorktreePath(p) ? ignored : changed).push({ path: p, xy });
}
return { dirty: changed.length > 0, changed, ignored };
}
// Discard a worktree's uncommitted committable changes: hard-reset tracked edits
// and remove untracked files. Excluded agent reports (info/exclude) are left in
// place (git clean without -x skips ignored/excluded). Returns { ok, message }.
function discardWorktreeChanges(wt) {
if (!wt || !_isRepo(wt)) return { ok: false, message: 'No worktree to clean.' };
const reset = _gitTry(['reset', '--hard', 'HEAD'], wt);
if (!reset.ok) return { ok: false, message: reset.err.split('\n').slice(-2).join(' ').slice(0, 300) || 'Reset failed.' };
_gitTry(['clean', '-fd'], wt); // remove untracked (keeps excluded reports)
return { ok: true, message: 'Discarded uncommitted changes.' };
}
function commitAll(wt, { message } = {}) {
if (!wt || !_isRepo(wt)) return { ok: false, committed: false, files: 0, message: 'No worktree to commit.' };
// Only committable paths — build output, generated reports and agent artifacts
// are classified as ignorable and must never be swept into the user's PR, even
// though git itself would happily stage them under `git add -A`.
const wc = worktreeChanges(wt);
const files = wc.changed.length;
if (!files) return { ok: true, committed: false, files: 0, message: 'Nothing to commit.' };
_ensureGitIdentity(wt);
const paths = wc.changed.map(c => c.path);
const add = _gitTry(['add', '--', ...paths], wt); // stages edits, adds and deletions
if (!add.ok) return { ok: false, committed: false, files, message: add.err.slice(0, 300) || 'git add failed.' };
const msg = String(message || '').trim() || 'Commit local changes';
const res = _gitTry(['commit', '-m', msg], wt);
if (!res.ok) return { ok: false, committed: false, files, message: res.err.split('\n').slice(-2).join(' ').slice(0, 300) || 'Commit failed.' };
return { ok: true, committed: true, files, message: 'Committed ' + files + ' change' + (files === 1 ? '' : 's') + '.' };
}
// Drift of a worktree vs a specific remote PR/source branch (origin/<branch>).
// Unlike worktreeStatus (which compares to @{u} or origin/base), this always
// compares the local HEAD to the PR's own source branch on origin — the right
// signal for "does my local checkout match the PR branch?". Optionally fetches.
// Returns { sourceBranch, localHead, remoteHead, ahead, behind, dirty, comparable, inSync, lastChecked } or null.
function prDrift(wt, sourceBranch, { fetch = true, desc = null } = {}) {
if (!wt || !_isRepo(wt)) return null;
const src = String(sourceBranch || '').replace(/^refs\/heads\//, '').trim();
if (fetch) _gitTry(['fetch', '--prune', 'origin'], wt, { auth: desc || true });
const localHead = _gitTry(['rev-parse', 'HEAD'], wt).out || '';
const remoteRef = src ? 'origin/' + src : '';
const remoteHead = src ? (_gitTry(['rev-parse', '--verify', '--quiet', remoteRef], wt).out || '') : '';
let ahead = 0, behind = 0, comparable = false;
if (src && remoteHead) {
const counts = _gitTry(['rev-list', '--left-right', '--count', remoteRef + '...HEAD'], wt);
if (counts.ok) {
const m = counts.out.split(/\s+/);
behind = parseInt(m[0], 10) || 0;
ahead = parseInt(m[1], 10) || 0;
comparable = true;
}
}
const cls = classifyPorcelain(_gitTry(['status', '--porcelain', '-uall'], wt).out || '');
const dirty = cls.dirty;
const inSync = comparable && ahead === 0 && behind === 0 && !dirty;
return {
sourceBranch: src, localHead, remoteHead,
ahead, behind, dirty, comparable, inSync,
ignoredReports: cls.ignored,
lastChecked: new Date().toISOString()
};
}
// One authoritative snapshot for the Dev Card's answer-first worktree UI.
// Keeps the comparisons distinct:
// local files -> local HEAD -> remote PR branch -> target branch
// and, when a separate PR worktree exists, compares its HEAD + file-status set.
function worktreeReadiness(wt, {
sourceBranch = '', targetBranch = 'main', prWorktreePath = '', fetch = true,
desc = null
} = {}) {
if (!wt || !_isRepo(wt)) return null;
if (fetch) _gitTry(['fetch', '--prune', 'origin'], wt, { auth: desc || true });
const norm = (b) => String(b || '').replace(/^refs\/heads\//, '').replace(/^origin\//, '').trim();
const localBranch = _gitTry(['rev-parse', '--abbrev-ref', 'HEAD'], wt).out || '';
const localHead = _gitTry(['rev-parse', 'HEAD'], wt).out || '';
const upstream = _gitTry(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], wt);
const remoteBranch = norm(sourceBranch) || norm(upstream.ok ? upstream.out : '') || localBranch;
const target = norm(targetBranch) || 'main';
const compare = (remote) => {
const ref = remote ? 'origin/' + remote : '';
const head = ref ? (_gitTry(['rev-parse', '--verify', '--quiet', ref], wt).out || '') : '';
let ahead = 0, behind = 0, comparable = false;
if (head) {
const counts = _gitTry(['rev-list', '--left-right', '--count', ref + '...HEAD'], wt);
if (counts.ok) {
const m = counts.out.split(/\s+/);
behind = parseInt(m[0], 10) || 0;
ahead = parseInt(m[1], 10) || 0;
comparable = true;
}
}
return { branch: remote, head, ahead, behind, comparable, inSync: comparable && ahead === 0 && behind === 0 };
};
const remote = compare(remoteBranch);
const base = compare(target);
const changes = listWorktreeFiles(wt);
const cls = classifyPorcelain(_gitTry(['status', '--porcelain', '-uall'], wt).out || '');
const branchFiles = [];