@webpieces/repo-workflow-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +31 -0
  2. package/src/agent-worktree-lock.d.ts +21 -0
  3. package/src/agent-worktree-lock.js +77 -0
  4. package/src/agent-worktree-lock.js.map +1 -0
  5. package/src/branch-archiver.d.ts +70 -0
  6. package/src/branch-archiver.js +157 -0
  7. package/src/branch-archiver.js.map +1 -0
  8. package/src/branch-identity.d.ts +6 -0
  9. package/src/branch-identity.js +59 -0
  10. package/src/branch-identity.js.map +1 -0
  11. package/src/branch-mutation-log.d.ts +77 -0
  12. package/src/branch-mutation-log.js +221 -0
  13. package/src/branch-mutation-log.js.map +1 -0
  14. package/src/branch-reaper.d.ts +122 -0
  15. package/src/branch-reaper.js +261 -0
  16. package/src/branch-reaper.js.map +1 -0
  17. package/src/harness-agent-activity.d.ts +154 -0
  18. package/src/harness-agent-activity.js +346 -0
  19. package/src/harness-agent-activity.js.map +1 -0
  20. package/src/index.d.ts +14 -0
  21. package/src/index.js +18 -0
  22. package/src/index.js.map +1 -0
  23. package/src/main-sync-file.d.ts +121 -0
  24. package/src/main-sync-file.js +281 -0
  25. package/src/main-sync-file.js.map +1 -0
  26. package/src/main-sync-status.d.ts +197 -0
  27. package/src/main-sync-status.js +555 -0
  28. package/src/main-sync-status.js.map +1 -0
  29. package/src/merged-branch-verdicts.d.ts +189 -0
  30. package/src/merged-branch-verdicts.js +251 -0
  31. package/src/merged-branch-verdicts.js.map +1 -0
  32. package/src/merged-branches.d.ts +140 -0
  33. package/src/merged-branches.js +521 -0
  34. package/src/merged-branches.js.map +1 -0
  35. package/src/review-identity-stamp.d.ts +82 -0
  36. package/src/review-identity-stamp.js +172 -0
  37. package/src/review-identity-stamp.js.map +1 -0
  38. package/src/worktree-lock-verdicts.d.ts +149 -0
  39. package/src/worktree-lock-verdicts.js +225 -0
  40. package/src/worktree-lock-verdicts.js.map +1 -0
  41. package/src/worktree-reaper.d.ts +116 -0
  42. package/src/worktree-reaper.js +318 -0
  43. package/src/worktree-reaper.js.map +1 -0
  44. package/src/worktrees.d.ts +113 -0
  45. package/src/worktrees.js +260 -0
  46. package/src/worktrees.js.map +1 -0
@@ -0,0 +1,555 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MainSyncStatusService = exports.MainSyncLock = exports.DEFAULT_HANG_TIMEOUT_MINUTES = exports.MAIN_SYNC_STATUS_VERSION = exports.PullRequestIndex = exports.MainSyncStatusFile = exports.MainSyncStatus = void 0;
4
+ exports.mainSyncStatusPath = mainSyncStatusPath;
5
+ exports.mainSyncLockPath = mainSyncLockPath;
6
+ exports.readMainSyncStatus = readMainSyncStatus;
7
+ exports.readMainSyncStatusFile = readMainSyncStatusFile;
8
+ exports.writeMainSyncStatus = writeMainSyncStatus;
9
+ exports.writeMainSyncStatusFile = writeMainSyncStatusFile;
10
+ exports.readMainSyncLock = readMainSyncLock;
11
+ exports.writeMainSyncLock = writeMainSyncLock;
12
+ exports.isLockStale = isLockStale;
13
+ exports.isRefreshInProgress = isRefreshInProgress;
14
+ exports.tryAcquireMainSyncLock = tryAcquireMainSyncLock;
15
+ exports.inProcessLock = inProcessLock;
16
+ exports.finishedLock = finishedLock;
17
+ exports.computeMainSyncStatus = computeMainSyncStatus;
18
+ exports.computeAllMainSyncStatuses = computeAllMainSyncStatuses;
19
+ exports.squashRecoverySteps = squashRecoverySteps;
20
+ exports.stampCleanMainSyncStatus = stampCleanMainSyncStatus;
21
+ const tslib_1 = require("tslib");
22
+ const child_process_1 = require("child_process");
23
+ const fs = tslib_1.__importStar(require("fs"));
24
+ const path = tslib_1.__importStar(require("path"));
25
+ const inversify_1 = require("inversify");
26
+ const tooling_common_1 = require("@webpieces/tooling-common");
27
+ const main_sync_file_1 = require("./main-sync-file");
28
+ Object.defineProperty(exports, "MAIN_SYNC_STATUS_VERSION", { enumerable: true, get: function () { return main_sync_file_1.MAIN_SYNC_STATUS_VERSION; } });
29
+ Object.defineProperty(exports, "MainSyncStatus", { enumerable: true, get: function () { return main_sync_file_1.MainSyncStatus; } });
30
+ Object.defineProperty(exports, "MainSyncStatusFile", { enumerable: true, get: function () { return main_sync_file_1.MainSyncStatusFile; } });
31
+ Object.defineProperty(exports, "PullRequestIndex", { enumerable: true, get: function () { return main_sync_file_1.PullRequestIndex; } });
32
+ const tooling_common_2 = require("@webpieces/tooling-common");
33
+ const to_error_1 = require("@webpieces/tooling-common/to-error");
34
+ const worktrees_1 = require("./worktrees");
35
+ // Shared "is my feature branch healthy relative to origin/main?" state. The SLOW signals (git fetch +
36
+ // merge-base + same-file-overlap + a merged-PR lookup) are computed by the ai-hook-rules refresher in a
37
+ // DETACHED background process; the feature-branch-guard only READS this cached file. pr-gate's merge
38
+ // flow also writes it synchronously after a merge. Lives here (the shared dep of both).
39
+ // How long an `inprocess` refresher lock may sit before a new refresher assumes the prior run hung.
40
+ exports.DEFAULT_HANG_TIMEOUT_MINUTES = 5;
41
+ const MAIN_SYNC_STATUS_FILE = 'main-sync-status.json';
42
+ const MAIN_SYNC_LOCK_FILE = 'main-sync.lock.json';
43
+ const LOCK_STATE_INPROCESS = 'inprocess';
44
+ const LOCK_STATE_FINISHED = 'finished';
45
+ // Concurrency state machine for the detached refresher. `started` is epoch ms. `pid` is the refresher
46
+ // process's pid (0 = unknown) — used so a KILLED refresher doesn't wedge `inprocess` for the timeout.
47
+ class MainSyncLock {
48
+ state;
49
+ started;
50
+ pid;
51
+ constructor(state, started, pid = 0) {
52
+ this.state = state;
53
+ this.started = started;
54
+ this.pid = pid;
55
+ }
56
+ }
57
+ exports.MainSyncLock = MainSyncLock;
58
+ /**
59
+ * Reads/writes the main-sync cache + lock and computes the slow "is my branch healthy vs origin/main?"
60
+ * status. `@injectable(bindingScopeValues.Singleton)` so it's injectable and drawn in consumers’ DI designs.
61
+ */
62
+ let MainSyncStatusService = class MainSyncStatusService {
63
+ dotDir;
64
+ atomicFile;
65
+ store;
66
+ worktrees;
67
+ constructor(dotDir = tooling_common_2.dotWebpieces, atomicFile = new tooling_common_1.AtomicFile(), store = new main_sync_file_1.MainSyncFileStore(atomicFile), worktrees = new worktrees_1.WorktreeService()) {
68
+ this.dotDir = dotDir;
69
+ this.atomicFile = atomicFile;
70
+ this.store = store;
71
+ this.worktrees = worktrees;
72
+ }
73
+ /**
74
+ * SHARED scope: `<primary>/.webpieces/main-sync-status.json`, one per repo.
75
+ *
76
+ * It is the OUTPUT of the single-flight refresher below, so it has to live where the lock lives —
77
+ * one `.git`, one `origin/main`, one fetch, one answer. A per-worktree copy under single-flight
78
+ * would only ever be written for whichever worktree won the lock anyway.
79
+ *
80
+ * FORMERLY a known defect, now fixed: the file's CONTENT used to be keyed to ONE branch, and every
81
+ * reader fails OPEN when the cached branch is not the branch being judged (`stale-cross-branch-cache
82
+ * (fail-open)`). With several worktrees on several branches, only the winner of the lock was ever
83
+ * described, so every other worktree's guards abstained — and which one was armed thrashed as the
84
+ * lock changed hands. The file is now a MAP of branch -> status (see MainSyncStatusFile): the single
85
+ * winning refresher enumerates every worktree and records all of their branches in one atomic write,
86
+ * so one fetch arms everyone. Single-flight is unchanged; only what the winner writes changed.
87
+ */
88
+ mainSyncStatusPath(repoRoot) {
89
+ return this.dotDir.sharedFile(repoRoot, MAIN_SYNC_STATUS_FILE);
90
+ }
91
+ /**
92
+ * SHARED scope — the single-flight lock for the whole repo.
93
+ *
94
+ * This is the fix for the filed bug "the detached main-sync refresher's `git fetch` races the
95
+ * agent's foreground `git fetch`/`git pull` and corrupts `.git/FETCH_HEAD`". The lock was already
96
+ * atomic (O_CREAT|O_EXCL) and already serialised refreshers against each other — but only WITHIN
97
+ * one worktree, so N worktrees meant N locks and up to N concurrent `git fetch`es against the ONE
98
+ * shared `.git`, which is exactly how a duplicate `for-merge` line lands in FETCH_HEAD. There is
99
+ * one `.git`, so there is now one lock: at most one refresher in flight across all worktrees.
100
+ *
101
+ * Stale-lock handling is unchanged and still required (a lock nobody can clear is worse than the
102
+ * race): the holder records pid + start epoch, and `tryAcquireMainSyncLock` reclaims it once
103
+ * `isRefreshInProgress` proves the holder is finished, hung past hangTimeoutMinutes, or dead.
104
+ */
105
+ mainSyncLockPath(repoRoot) {
106
+ return this.dotDir.sharedFile(repoRoot, MAIN_SYNC_LOCK_FILE);
107
+ }
108
+ /**
109
+ * The cached status FOR ONE BRANCH — the only read the four main-sync guards perform.
110
+ *
111
+ * `branch` is mandatory and is the branch being judged. A branch the last refresh never saw
112
+ * returns null, which every guard already treats as `no-sync-cache (fail-open)`, so an unknown
113
+ * branch degrades exactly as a missing file did. Any error (missing, truncated, malformed JSON)
114
+ * also returns null — this must never throw on the blocking path.
115
+ */
116
+ readMainSyncStatus(repoRoot, branch) {
117
+ return this.store.branchStatus(this.readMainSyncStatusFile(repoRoot), branch);
118
+ }
119
+ // The whole branch-keyed document, or null. Callers that need more than one branch use this.
120
+ readMainSyncStatusFile(repoRoot) {
121
+ return this.store.readFile(this.mainSyncStatusPath(repoRoot));
122
+ }
123
+ /**
124
+ * Merge ONE branch's entry into the existing map, atomically, leaving siblings untouched.
125
+ *
126
+ * Read-modify-write rather than replace: this is the writer used by everyone who is NOT the
127
+ * single-flight refresher (notably pr-gate's post-merge stamp, which holds no lock and knows about
128
+ * exactly one branch). Replacing the document from there would erase every other worktree's entry
129
+ * and silently disarm their guards until the next refresh.
130
+ */
131
+ writeMainSyncStatus(repoRoot, status) {
132
+ this.store.mergeBranch(this.mainSyncStatusPath(repoRoot), status);
133
+ }
134
+ /**
135
+ * Replace the WHOLE document — only the winning refresher may do this.
136
+ *
137
+ * ATOMIC (temp file + `rename()` in the same directory). The refresher writes this file while
138
+ * guards on the blocking path read it, possibly from another worktree; a plain `writeFileSync`
139
+ * truncates first, so a reader landing in that window gets a torn document. See AtomicFile.
140
+ */
141
+ writeMainSyncStatusFile(repoRoot, file) {
142
+ this.store.writeFile(this.mainSyncStatusPath(repoRoot), file);
143
+ }
144
+ readMainSyncLock(repoRoot) {
145
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
146
+ try {
147
+ const lockPath = this.mainSyncLockPath(repoRoot);
148
+ if (!fs.existsSync(lockPath))
149
+ return null;
150
+ const raw = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
151
+ return new MainSyncLock(raw.state ?? LOCK_STATE_FINISHED, raw.started ?? 0, raw.pid ?? 0);
152
+ }
153
+ catch (err) {
154
+ const error = (0, to_error_1.toError)(err);
155
+ void error;
156
+ return null;
157
+ }
158
+ }
159
+ writeMainSyncLock(repoRoot, lock) {
160
+ const lockPath = this.mainSyncLockPath(repoRoot);
161
+ this.ensureDir(lockPath);
162
+ fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
163
+ }
164
+ // A lock is stale (prior refresher assumed hung) once `inprocess` longer than hangTimeoutMinutes.
165
+ isLockStale(lock, hangTimeoutMinutes, now = Date.now()) {
166
+ return now - lock.started > hangTimeoutMinutes * 60 * 1000;
167
+ }
168
+ // True when another refresher is actively running and we should NOT start a second one.
169
+ isRefreshInProgress(repoRoot, hangTimeoutMinutes, now = Date.now()) {
170
+ const lock = this.readMainSyncLock(repoRoot);
171
+ if (!lock)
172
+ return false;
173
+ if (lock.state !== LOCK_STATE_INPROCESS)
174
+ return false;
175
+ if (this.isLockStale(lock, hangTimeoutMinutes, now))
176
+ return false;
177
+ return this.isProcessAlive(lock.pid);
178
+ }
179
+ /**
180
+ * ATOMICALLY take the refresher lock. Returns the held lock, or null when someone else holds it.
181
+ *
182
+ * Replaces the check-then-write pair (`isRefreshInProgress` then `writeMainSyncLock`), whose gap
183
+ * let two detached refreshers both pass the check and then both run `git fetch` at once — the
184
+ * concurrency this lock exists to prevent. The create uses the `wx` flag (O_CREAT|O_EXCL), so of
185
+ * N racing refreshers exactly one creates the file.
186
+ *
187
+ * A lock file left behind by a FINISHED, hung, or killed refresher is reclaimed: we only unlink
188
+ * and re-take it once `isRefreshInProgress` says the holder is provably not running, and we
189
+ * re-read afterwards to confirm the entry we see is ours before claiming the lock.
190
+ */
191
+ tryAcquireMainSyncLock(repoRoot, hangTimeoutMinutes, now = Date.now(), pid = process.pid) {
192
+ const lockPath = this.mainSyncLockPath(repoRoot);
193
+ this.ensureDir(lockPath);
194
+ const lock = this.inProcessLock(now, pid);
195
+ const payload = JSON.stringify(lock, null, 2) + '\n';
196
+ if (this.createExclusive(lockPath, payload))
197
+ return lock;
198
+ // The file already exists. Leave it alone while a live refresher owns it.
199
+ if (this.isRefreshInProgress(repoRoot, hangTimeoutMinutes, now))
200
+ return null;
201
+ // Reclaim it: remove the dead entry, then re-take it exclusively so only one reclaimer wins.
202
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
203
+ try {
204
+ fs.unlinkSync(lockPath);
205
+ }
206
+ catch (err) {
207
+ const error = (0, to_error_1.toError)(err);
208
+ void error; // someone else reclaimed it first — the exclusive create below decides
209
+ }
210
+ if (!this.createExclusive(lockPath, payload))
211
+ return null;
212
+ // Confirm the lock on disk is OURS (a simultaneous reclaimer could have unlinked ours and
213
+ // written its own between the two calls above).
214
+ const held = this.readMainSyncLock(repoRoot);
215
+ if (!held || held.pid !== pid || held.started !== lock.started)
216
+ return null;
217
+ return lock;
218
+ }
219
+ // O_CREAT|O_EXCL write: true when THIS call created the file, false when it already existed.
220
+ createExclusive(lockPath, payload) {
221
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
222
+ try {
223
+ fs.writeFileSync(lockPath, payload, { flag: 'wx' });
224
+ return true;
225
+ }
226
+ catch (err) {
227
+ const error = (0, to_error_1.toError)(err);
228
+ void error;
229
+ return false;
230
+ }
231
+ }
232
+ inProcessLock(now = Date.now(), pid = process.pid) {
233
+ return new MainSyncLock(LOCK_STATE_INPROCESS, now, pid);
234
+ }
235
+ finishedLock(started) {
236
+ return new MainSyncLock(LOCK_STATE_FINISHED, started, 0);
237
+ }
238
+ /**
239
+ * The SLOW path for ONE branch: the branch checked out in `repoRoot`. Runs its own fetch and its
240
+ * own `gh` query, so it is the right entry point for a single-worktree caller and for tests.
241
+ * The refresher uses computeAllMainSyncStatuses instead — see there for why.
242
+ */
243
+ computeMainSyncStatus(repoRoot) {
244
+ // Best-effort network refresh; offline just means we evaluate against the last-fetched ref.
245
+ this.fetchOriginMain(repoRoot);
246
+ return this.computeBranchStatus(repoRoot, this.gitBranch(repoRoot), this.loadPullRequestIndex(repoRoot));
247
+ }
248
+ /**
249
+ * The SLOW path for EVERY branch this repo has checked out — what the detached refresher runs.
250
+ *
251
+ * The cache is shared by all worktrees but was written for one branch, so only one worktree's
252
+ * guards were ever armed. Enumerating the worktrees here and writing all of their branches in one
253
+ * atomic map arms every worktree off a single refresh.
254
+ *
255
+ * NETWORK COST DOES NOT SCALE WITH BRANCH COUNT, which is the whole reason this is a separate
256
+ * method: ONE `git fetch` and ONE repo-wide `gh pr list` for all N branches (the old code asked
257
+ * `gh` twice per branch, so looping it would have cost 2N round trips). Everything after that is
258
+ * local git in each worktree — merge-base, rev-parse, diff, ls-files.
259
+ *
260
+ * The map is rebuilt from the LIVE worktree set every refresh, so a deleted branch or removed
261
+ * worktree simply stops appearing. No pruning pass, no unbounded growth.
262
+ */
263
+ computeAllMainSyncStatuses(repoRoot) {
264
+ this.fetchOriginMain(repoRoot);
265
+ const index = this.loadPullRequestIndex(repoRoot);
266
+ const branches = {};
267
+ for (const tree of this.worktrees.listWorktrees(repoRoot)) {
268
+ // Detached HEAD (and a bare worktree) has no branch to key an entry on. Skipping it must
269
+ // not stop the others being recorded, which is why this is a `continue`, not a bail-out.
270
+ if (tree.branch === '' || this.hasBranch(branches, tree.branch))
271
+ continue;
272
+ branches[tree.branch] = this.computeBranchStatus(tree.path, tree.branch, index);
273
+ }
274
+ // `main` always present, even with no worktree on it: read-stale-guard's state A looks itself
275
+ // up under 'main', and an absent entry there would silently disarm the stale-main block.
276
+ if (!this.hasBranch(branches, 'main'))
277
+ branches['main'] = this.mainOnlyStatus(repoRoot);
278
+ return new main_sync_file_1.MainSyncStatusFile(main_sync_file_1.MAIN_SYNC_STATUS_VERSION, new Date().toISOString(), branches);
279
+ }
280
+ hasBranch(branches, branch) {
281
+ return Object.prototype.hasOwnProperty.call(branches, branch);
282
+ }
283
+ /**
284
+ * One branch's status, computed in `worktreePath` (the tree that has it checked out — the
285
+ * working-tree overlap signals are only correct from there). Does NOT fetch and does NOT call
286
+ * `gh`: both are done once per refresh by the caller.
287
+ */
288
+ // webpieces-disable max-lines-new-methods -- one cohesive slow-path computation
289
+ computeBranchStatus(repoRoot, branch, index) {
290
+ return this.stampForge(this.computeBranchStatusInner(repoRoot, branch, index), index);
291
+ }
292
+ // Every return path of the computation below has to carry the forge flag, and threading it through
293
+ // four constructions is how one of them would end up missing it. One wrapper, one assignment.
294
+ stampForge(status, index) {
295
+ status.forgeReachable = index.forgeReachable;
296
+ return status;
297
+ }
298
+ // webpieces-disable max-lines-new-methods -- one cohesive slow-path computation
299
+ computeBranchStatusInner(repoRoot, branch, index) {
300
+ const mergedPr = index.mergedFor(branch);
301
+ const openPr = index.openFor(branch);
302
+ const head = this.capture(repoRoot, 'git', ['rev-parse', 'HEAD']);
303
+ const originMain = this.capture(repoRoot, 'git', ['rev-parse', 'origin/main']);
304
+ const localMain = this.localMainHash(repoRoot);
305
+ const featureHead = head.ok ? head.out : '';
306
+ if (!head.ok || !originMain.ok) {
307
+ const status = this.benignStatus(branch, featureHead);
308
+ status.branchAlreadyMerged = mergedPr !== '';
309
+ status.mergedPr = mergedPr;
310
+ status.openPr = openPr;
311
+ status.localMain = localMain;
312
+ status.commitsBehind = this.commitDistance(repoRoot, localMain, originMain.ok ? originMain.out : '');
313
+ return status;
314
+ }
315
+ const forkPoint = this.capture(repoRoot, 'git', ['merge-base', 'origin/main', 'HEAD']);
316
+ if (!forkPoint.ok || forkPoint.out === '') {
317
+ const noFork = new main_sync_file_1.MainSyncStatus(branch, mergedPr !== '', mergedPr, false, null, originMain.out, featureHead, false, [], new Date().toISOString());
318
+ noFork.openPr = openPr;
319
+ noFork.localMain = localMain;
320
+ noFork.commitsBehind = this.commitDistance(repoRoot, localMain, originMain.out);
321
+ return noFork;
322
+ }
323
+ const featureFiles = new Set(this.featureChangedFiles(repoRoot, forkPoint.out));
324
+ const mainFiles = this.changedFiles(repoRoot, forkPoint.out, 'origin/main');
325
+ const conflictFiles = mainFiles.filter((file) => featureFiles.has(file));
326
+ const status = new main_sync_file_1.MainSyncStatus(branch, mergedPr !== '', mergedPr, true, forkPoint.out, originMain.out, featureHead, conflictFiles.length > 0, conflictFiles, new Date().toISOString());
327
+ status.openPr = openPr;
328
+ status.localMain = localMain;
329
+ status.commitsBehind = this.commitDistance(repoRoot, localMain, originMain.out);
330
+ return status;
331
+ }
332
+ // The recovery steps when there is no fork point with origin/main (a bad merge of main into branch).
333
+ squashRecoverySteps(currentBranch) {
334
+ return [
335
+ '1. Fetch latest main: git fetch origin main',
336
+ `2. New branch off origin/main: git checkout -b ${currentBranch}-v2 origin/main`,
337
+ `3. Squash-merge old branch: git merge --squash ${currentBranch}`,
338
+ ' ^^ HUMAN-ONLY. `git merge` is blocked for AI (redirect-how-to-merge-main). AI: ask the',
339
+ ' human to run step 3, and warn them it is a raw merge — only correct here because the',
340
+ ' branch is already broken. For a normal update from main they should push back and',
341
+ ' tell you to use the gated 3-point merge instead: `pnpm wp-start-update` (paired with',
342
+ ' `pnpm wp-finish-update`) when no PR is open, or `pnpm wp-start-upsert-pr` (paired with',
343
+ ' `pnpm wp-finish-upsert-pr`) when a PR IS open — a PR MUST use the upsert-pr pair.',
344
+ `4. Commit the squash: git add -A && git commit -m "Squashed from ${currentBranch}"`,
345
+ '5. If a PR exists: open a NEW PR for the -v2 branch and close the old one.',
346
+ ];
347
+ }
348
+ /**
349
+ * Synchronously stamp a clean "up to date with main" status — call right after a successful merge.
350
+ *
351
+ * This is the one writer that takes NO lock, and it knows about exactly one branch. It therefore
352
+ * goes through writeMainSyncStatus, which MERGES its entry into the existing map; writing a whole
353
+ * document from here would wipe every other worktree's entry.
354
+ */
355
+ stampCleanMainSyncStatus(repoRoot) {
356
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
357
+ try {
358
+ const branch = this.gitBranch(repoRoot);
359
+ const originMain = this.capture(repoRoot, 'git', ['rev-parse', 'origin/main']);
360
+ const featureHead = this.capture(repoRoot, 'git', ['rev-parse', 'HEAD']);
361
+ if (!originMain.ok || !featureHead.ok)
362
+ return;
363
+ const status = new main_sync_file_1.MainSyncStatus(branch, false, '', true, originMain.out, originMain.out, featureHead.out, false, [], new Date().toISOString());
364
+ status.localMain = this.localMainHash(repoRoot);
365
+ status.commitsBehind = 0;
366
+ this.writeMainSyncStatus(repoRoot, status);
367
+ }
368
+ catch (err) {
369
+ const error = (0, to_error_1.toError)(err);
370
+ void error;
371
+ }
372
+ }
373
+ /**
374
+ * Refresh `origin/main` WITHOUT writing `.git/FETCH_HEAD`.
375
+ *
376
+ * WHY the flag is not optional: this runs in a DETACHED background process while the agent is
377
+ * running its own foreground `git fetch` / `git pull` in the very same repo. `.git/FETCH_HEAD` is
378
+ * a single file that git takes no lock on, so two overlapping fetches interleave their writes and
379
+ * can leave the SAME `for-merge` line twice. `git pull` then reads two for-merge entries and dies
380
+ * with `fatal: Cannot fast-forward to multiple branches` — wedging the exact command the
381
+ * read-stale-guard tells the agent to run. `--no-write-fetch-head` (git >= 2.29) still updates the
382
+ * remote-tracking ref, which is all anything downstream reads (`origin/main`, merge-base), so
383
+ * nothing here needs FETCH_HEAD written at all.
384
+ *
385
+ * Older git rejects the flag; only then do we retry the plain form, accepting the old behaviour
386
+ * rather than losing the refresh entirely on a pre-2020 git.
387
+ */
388
+ fetchOriginMain(repoRoot) {
389
+ const safe = (0, child_process_1.spawnSync)('git', ['fetch', '--no-write-fetch-head', 'origin', 'main'], {
390
+ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'ignore', 'pipe'],
391
+ });
392
+ if (safe.status === 0)
393
+ return;
394
+ if (!this.isUnknownGitOption(safe.stderr))
395
+ return; // a real failure (offline, auth) — not our business
396
+ (0, child_process_1.spawnSync)('git', ['fetch', 'origin', 'main'], { cwd: repoRoot, stdio: 'ignore' });
397
+ }
398
+ // Did git reject the flag itself (too old), as opposed to failing the network refresh?
399
+ isUnknownGitOption(stderr) {
400
+ const text = (stderr ?? '').toLowerCase();
401
+ return text.includes('unknown option') || text.includes('unknown switch') || text.includes('unrecognized option');
402
+ }
403
+ ensureDir(filePath) {
404
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
405
+ }
406
+ // Liveness probe: is `pid` still a running process? pid <= 0 (unknown) → assume alive.
407
+ isProcessAlive(pid) {
408
+ if (pid <= 0)
409
+ return true;
410
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
411
+ try {
412
+ process.kill(pid, 0);
413
+ return true;
414
+ }
415
+ catch (err) {
416
+ const error = (0, to_error_1.toError)(err);
417
+ return !error.message.includes('ESRCH');
418
+ }
419
+ }
420
+ // Run a command capturing trimmed stdout; ok=false on spawn failure or non-zero exit.
421
+ capture(repoRoot, cmd, args) {
422
+ const result = (0, child_process_1.spawnSync)(cmd, args, { cwd: repoRoot, encoding: 'utf8' });
423
+ if (result.status !== 0 || typeof result.stdout !== 'string')
424
+ return { ok: false, out: '' };
425
+ return { ok: true, out: result.stdout.trim() };
426
+ }
427
+ // The actual checked-out branch in repoRoot — cwd-correct so the cache's `branch` label matches.
428
+ gitBranch(repoRoot) {
429
+ const result = this.capture(repoRoot, 'git', ['rev-parse', '--abbrev-ref', 'HEAD']);
430
+ return result.ok ? result.out : '';
431
+ }
432
+ // The LOCAL main hash. `refs/heads/main` (not the bare name) so it can never resolve to a remote
433
+ // ref or a tag. '' when main does not exist locally — which the guard treats as fail-open.
434
+ localMainHash(repoRoot) {
435
+ const result = this.capture(repoRoot, 'git', ['rev-parse', 'refs/heads/main']);
436
+ return result.ok ? result.out : '';
437
+ }
438
+ // Slow-path only: the detached refresher calls this after fetching. Guards never count commits.
439
+ commitDistance(repoRoot, localMain, originMain) {
440
+ if (localMain === '' || originMain === '')
441
+ return null;
442
+ const result = this.capture(repoRoot, 'git', ['rev-list', '--count', `${localMain}..${originMain}`]);
443
+ if (!result.ok || !/^\d+$/.test(result.out))
444
+ return null;
445
+ return Number(result.out);
446
+ }
447
+ changedFiles(repoRoot, base, head) {
448
+ const result = this.capture(repoRoot, 'git', ['diff', '--name-only', base, head]);
449
+ if (!result.ok || result.out === '')
450
+ return [];
451
+ return result.out.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
452
+ }
453
+ // Every file this feature branch has touched since the fork point — committed AND still in the
454
+ // working tree (staged / unstaged / untracked), so a conflict is visible WHILE editing.
455
+ featureChangedFiles(repoRoot, forkPoint) {
456
+ const out = new Set();
457
+ const add = (args) => {
458
+ const r = this.capture(repoRoot, 'git', args);
459
+ if (!r.ok || r.out === '')
460
+ return;
461
+ for (const line of r.out.split('\n')) {
462
+ const f = line.trim();
463
+ if (f.length > 0)
464
+ out.add(f);
465
+ }
466
+ };
467
+ add(['diff', '--name-only', forkPoint, 'HEAD']); // committed since the fork point
468
+ add(['diff', '--name-only', 'HEAD']); // unstaged working-tree edits
469
+ add(['diff', '--name-only', '--cached', 'HEAD']); // staged edits
470
+ add(['ls-files', '--others', '--exclude-standard']); // untracked new files (respects .gitignore)
471
+ return [...out];
472
+ }
473
+ /**
474
+ * ONE repo-wide `gh pr list` giving every branch's merged/open PR — replaces the two per-branch
475
+ * `--head <branch>` queries. `--limit 200` bounds it; a branch whose only PR is older than that
476
+ * falls out of the index and reads as "no PR", which is the fail-OPEN direction (no merged PR =
477
+ * no block), the same way a missing `gh` or an offline machine already degraded.
478
+ */
479
+ loadPullRequestIndex(repoRoot) {
480
+ const result = this.capture(repoRoot, 'gh', [
481
+ 'pr', 'list', '--state', 'all', '--json', 'number,headRefName,state', '--limit', '200',
482
+ ]);
483
+ // `gh` could not be run, or exited non-zero (missing, unauthenticated, rate-limited, offline).
484
+ // An EMPTY index is the same fail-open answer as before — but it is now labelled UNREACHABLE, so
485
+ // the guards can report an abstention rather than an approval. Empty STDOUT on a zero exit is a
486
+ // real answer ("this repo has no PRs"), so only the failure path clears the flag.
487
+ if (!result.ok)
488
+ return new main_sync_file_1.PullRequestIndex({}, {}, false);
489
+ if (result.out === '')
490
+ return new main_sync_file_1.PullRequestIndex({}, {}, true);
491
+ return this.store.indexPullRequests(result.out);
492
+ }
493
+ /**
494
+ * A `main` entry synthesised WITHOUT a worktree standing on main. Carries only the two hashes
495
+ * read-stale-guard / stale-main-bash-guard actually compare (originMain vs the live tree's
496
+ * ancestry) — never merged, never conflicting, so it can only ever produce the stale-main verdict.
497
+ */
498
+ mainOnlyStatus(repoRoot) {
499
+ const originMain = this.capture(repoRoot, 'git', ['rev-parse', 'origin/main']);
500
+ const localMain = this.localMainHash(repoRoot);
501
+ const status = new main_sync_file_1.MainSyncStatus('main', false, '', true, localMain === '' ? null : localMain, originMain.ok ? originMain.out : '', localMain, false, [], new Date().toISOString());
502
+ status.localMain = localMain;
503
+ status.commitsBehind = this.commitDistance(repoRoot, localMain, originMain.ok ? originMain.out : '');
504
+ return status;
505
+ }
506
+ // A benign status that never blocks — used when origin/main can't be resolved.
507
+ benignStatus(branch, featureHead) {
508
+ return new main_sync_file_1.MainSyncStatus(branch, false, '', true, null, '', featureHead, false, [], new Date().toISOString());
509
+ }
510
+ };
511
+ exports.MainSyncStatusService = MainSyncStatusService;
512
+ exports.MainSyncStatusService = MainSyncStatusService = tslib_1.__decorate([
513
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
514
+ tslib_1.__metadata("design:paramtypes", [tooling_common_2.DotWebpieces,
515
+ tooling_common_1.AtomicFile,
516
+ main_sync_file_1.MainSyncFileStore,
517
+ worktrees_1.WorktreeService])
518
+ ], MainSyncStatusService);
519
+ // Temporary migration delegators to MainSyncStatusService — removed once consumers inject it.
520
+ const mainSyncSvc = new MainSyncStatusService();
521
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
522
+ function mainSyncStatusPath(repoRoot) { return mainSyncSvc.mainSyncStatusPath(repoRoot); }
523
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
524
+ function mainSyncLockPath(repoRoot) { return mainSyncSvc.mainSyncLockPath(repoRoot); }
525
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
526
+ function readMainSyncStatus(repoRoot, branch) { return mainSyncSvc.readMainSyncStatus(repoRoot, branch); }
527
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
528
+ function readMainSyncStatusFile(repoRoot) { return mainSyncSvc.readMainSyncStatusFile(repoRoot); }
529
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
530
+ function writeMainSyncStatus(repoRoot, status) { mainSyncSvc.writeMainSyncStatus(repoRoot, status); }
531
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
532
+ function writeMainSyncStatusFile(repoRoot, file) { mainSyncSvc.writeMainSyncStatusFile(repoRoot, file); }
533
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
534
+ function readMainSyncLock(repoRoot) { return mainSyncSvc.readMainSyncLock(repoRoot); }
535
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
536
+ function writeMainSyncLock(repoRoot, lock) { mainSyncSvc.writeMainSyncLock(repoRoot, lock); }
537
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
538
+ function isLockStale(lock, hangTimeoutMinutes, now = Date.now()) { return mainSyncSvc.isLockStale(lock, hangTimeoutMinutes, now); }
539
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
540
+ function isRefreshInProgress(repoRoot, hangTimeoutMinutes, now = Date.now()) { return mainSyncSvc.isRefreshInProgress(repoRoot, hangTimeoutMinutes, now); }
541
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
542
+ function tryAcquireMainSyncLock(repoRoot, hangTimeoutMinutes, now = Date.now(), pid = process.pid) { return mainSyncSvc.tryAcquireMainSyncLock(repoRoot, hangTimeoutMinutes, now, pid); }
543
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
544
+ function inProcessLock(now = Date.now(), pid = process.pid) { return mainSyncSvc.inProcessLock(now, pid); }
545
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
546
+ function finishedLock(started) { return mainSyncSvc.finishedLock(started); }
547
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
548
+ function computeMainSyncStatus(repoRoot) { return mainSyncSvc.computeMainSyncStatus(repoRoot); }
549
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
550
+ function computeAllMainSyncStatuses(repoRoot) { return mainSyncSvc.computeAllMainSyncStatuses(repoRoot); }
551
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
552
+ function squashRecoverySteps(currentBranch) { return mainSyncSvc.squashRecoverySteps(currentBranch); }
553
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
554
+ function stampCleanMainSyncStatus(repoRoot) { mainSyncSvc.stampCleanMainSyncStatus(repoRoot); }
555
+ //# sourceMappingURL=main-sync-status.js.map