@webpieces/rules-config 0.4.543 → 0.4.545

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,9 @@
1
1
  import { AtomicFile } from './atomic-file';
2
+ import { MAIN_SYNC_STATUS_VERSION, MainSyncFileStore, MainSyncStatus, MainSyncStatusFile, PullRequestIndex } from './main-sync-file';
2
3
  import { DotWebpieces } from './state-dir';
4
+ import { WorktreeService } from './worktrees';
5
+ export { MainSyncStatus, MainSyncStatusFile, PullRequestIndex, MAIN_SYNC_STATUS_VERSION };
3
6
  export declare const DEFAULT_HANG_TIMEOUT_MINUTES = 5;
4
- export declare class MainSyncStatus {
5
- branch: string;
6
- branchAlreadyMerged: boolean;
7
- mergedPr: string;
8
- hasForkPoint: boolean;
9
- forkPoint: string | null;
10
- originMain: string;
11
- featureHead: string;
12
- conflict: boolean;
13
- conflictFiles: string[];
14
- timestamp: string;
15
- openPr: string;
16
- localMain: string;
17
- constructor(branch: string, branchAlreadyMerged: boolean, mergedPr: string, hasForkPoint: boolean, forkPoint: string | null, originMain: string, featureHead: string, conflict: boolean, conflictFiles: string[], timestamp: string);
18
- }
19
7
  export declare class MainSyncLock {
20
8
  state: string;
21
9
  started: number;
@@ -29,7 +17,9 @@ export declare class MainSyncLock {
29
17
  export declare class MainSyncStatusService {
30
18
  private readonly dotDir;
31
19
  private readonly atomicFile;
32
- constructor(dotDir?: DotWebpieces, atomicFile?: AtomicFile);
20
+ private readonly store;
21
+ private readonly worktrees;
22
+ constructor(dotDir?: DotWebpieces, atomicFile?: AtomicFile, store?: MainSyncFileStore, worktrees?: WorktreeService);
33
23
  /**
34
24
  * SHARED scope: `<primary>/.webpieces/main-sync-status.json`, one per repo.
35
25
  *
@@ -37,14 +27,13 @@ export declare class MainSyncStatusService {
37
27
  * one `.git`, one `origin/main`, one fetch, one answer. A per-worktree copy under single-flight
38
28
  * would only ever be written for whichever worktree won the lock anyway.
39
29
  *
40
- * KNOWN CONSEQUENCE, stated plainly rather than discovered later: the file's CONTENT is keyed to
41
- * one branch (`branch`, `featureHead`, `conflict`, `conflictFiles`), and every reader fails OPEN
42
- * when the cached branch is not the branch being judged (`merged-branch-bash-guard` and
43
- * `stale-main-bash-guard` both log `stale-cross-branch-cache (fail-open)`). With several worktrees
44
- * on several branches, the losers of the lock therefore see a cross-branch cache and their guards
45
- * allow rather than block. That is the fail-SAFE direction and it is inherent to single-flight, not
46
- * to the shared path but making the entries branch-keyed (a map of branch → status, written
47
- * atomically) is the obvious follow-up.
30
+ * FORMERLY a known defect, now fixed: the file's CONTENT used to be keyed to ONE branch, and every
31
+ * reader fails OPEN when the cached branch is not the branch being judged (`stale-cross-branch-cache
32
+ * (fail-open)`). With several worktrees on several branches, only the winner of the lock was ever
33
+ * described, so every other worktree's guards abstained — and which one was armed thrashed as the
34
+ * lock changed hands. The file is now a MAP of branch -> status (see MainSyncStatusFile): the single
35
+ * winning refresher enumerates every worktree and records all of their branches in one atomic write,
36
+ * so one fetch arms everyone. Single-flight is unchanged; only what the winner writes changed.
48
37
  */
49
38
  mainSyncStatusPath(repoRoot: string): string;
50
39
  /**
@@ -62,14 +51,33 @@ export declare class MainSyncStatusService {
62
51
  * `isRefreshInProgress` proves the holder is finished, hung past hangTimeoutMinutes, or dead.
63
52
  */
64
53
  mainSyncLockPath(repoRoot: string): string;
65
- readMainSyncStatus(repoRoot: string): MainSyncStatus | null;
66
54
  /**
67
- * ATOMIC write (temp file + `rename()` in the same directory). The refresher writes this file while
68
- * guards on the blocking path read it; a plain `writeFileSync` truncates first, so a reader landing
69
- * in that window gets a torn document. Now that the file is repo-wide the reader may be in another
70
- * worktree entirely, which widens the window rather than creating it. See AtomicFile.
55
+ * The cached status FOR ONE BRANCH the only read the four main-sync guards perform.
56
+ *
57
+ * `branch` is mandatory and is the branch being judged. A branch the last refresh never saw
58
+ * returns null, which every guard already treats as `no-sync-cache (fail-open)`, so an unknown
59
+ * branch degrades exactly as a missing file did. Any error (missing, truncated, malformed JSON)
60
+ * also returns null — this must never throw on the blocking path.
61
+ */
62
+ readMainSyncStatus(repoRoot: string, branch: string): MainSyncStatus | null;
63
+ readMainSyncStatusFile(repoRoot: string): MainSyncStatusFile | null;
64
+ /**
65
+ * Merge ONE branch's entry into the existing map, atomically, leaving siblings untouched.
66
+ *
67
+ * Read-modify-write rather than replace: this is the writer used by everyone who is NOT the
68
+ * single-flight refresher (notably pr-gate's post-merge stamp, which holds no lock and knows about
69
+ * exactly one branch). Replacing the document from there would erase every other worktree's entry
70
+ * and silently disarm their guards until the next refresh.
71
71
  */
72
72
  writeMainSyncStatus(repoRoot: string, status: MainSyncStatus): void;
73
+ /**
74
+ * Replace the WHOLE document — only the winning refresher may do this.
75
+ *
76
+ * ATOMIC (temp file + `rename()` in the same directory). The refresher writes this file while
77
+ * guards on the blocking path read it, possibly from another worktree; a plain `writeFileSync`
78
+ * truncates first, so a reader landing in that window gets a torn document. See AtomicFile.
79
+ */
80
+ writeMainSyncStatusFile(repoRoot: string, file: MainSyncStatusFile): void;
73
81
  readMainSyncLock(repoRoot: string): MainSyncLock | null;
74
82
  writeMainSyncLock(repoRoot: string, lock: MainSyncLock): void;
75
83
  isLockStale(lock: MainSyncLock, hangTimeoutMinutes: number, now?: number): boolean;
@@ -91,11 +99,42 @@ export declare class MainSyncStatusService {
91
99
  inProcessLock(now?: number, pid?: number): MainSyncLock;
92
100
  finishedLock(started: number): MainSyncLock;
93
101
  /**
94
- * The SLOW path, run only inside the detached refresher. Computes every cached signal the
95
- * feature-branch-guard needs. Never run on the hook's blocking path.
102
+ * The SLOW path for ONE branch: the branch checked out in `repoRoot`. Runs its own fetch and its
103
+ * own `gh` query, so it is the right entry point for a single-worktree caller and for tests.
104
+ * The refresher uses computeAllMainSyncStatuses instead — see there for why.
96
105
  */
97
106
  computeMainSyncStatus(repoRoot: string): MainSyncStatus;
107
+ /**
108
+ * The SLOW path for EVERY branch this repo has checked out — what the detached refresher runs.
109
+ *
110
+ * The cache is shared by all worktrees but was written for one branch, so only one worktree's
111
+ * guards were ever armed. Enumerating the worktrees here and writing all of their branches in one
112
+ * atomic map arms every worktree off a single refresh.
113
+ *
114
+ * NETWORK COST DOES NOT SCALE WITH BRANCH COUNT, which is the whole reason this is a separate
115
+ * method: ONE `git fetch` and ONE repo-wide `gh pr list` for all N branches (the old code asked
116
+ * `gh` twice per branch, so looping it would have cost 2N round trips). Everything after that is
117
+ * local git in each worktree — merge-base, rev-parse, diff, ls-files.
118
+ *
119
+ * The map is rebuilt from the LIVE worktree set every refresh, so a deleted branch or removed
120
+ * worktree simply stops appearing. No pruning pass, no unbounded growth.
121
+ */
122
+ computeAllMainSyncStatuses(repoRoot: string): MainSyncStatusFile;
123
+ private hasBranch;
124
+ /**
125
+ * One branch's status, computed in `worktreePath` (the tree that has it checked out — the
126
+ * working-tree overlap signals are only correct from there). Does NOT fetch and does NOT call
127
+ * `gh`: both are done once per refresh by the caller.
128
+ */
129
+ private computeBranchStatus;
98
130
  squashRecoverySteps(currentBranch: string): string[];
131
+ /**
132
+ * Synchronously stamp a clean "up to date with main" status — call right after a successful merge.
133
+ *
134
+ * This is the one writer that takes NO lock, and it knows about exactly one branch. It therefore
135
+ * goes through writeMainSyncStatus, which MERGES its entry into the existing map; writing a whole
136
+ * document from here would wipe every other worktree's entry.
137
+ */
99
138
  stampCleanMainSyncStatus(repoRoot: string): void;
100
139
  /**
101
140
  * Refresh `origin/main` WITHOUT writing `.git/FETCH_HEAD`.
@@ -121,14 +160,27 @@ export declare class MainSyncStatusService {
121
160
  private localMainHash;
122
161
  private changedFiles;
123
162
  private featureChangedFiles;
124
- private detectMergedPr;
125
- private detectOpenPr;
163
+ /**
164
+ * ONE repo-wide `gh pr list` giving every branch's merged/open PR — replaces the two per-branch
165
+ * `--head <branch>` queries. `--limit 200` bounds it; a branch whose only PR is older than that
166
+ * falls out of the index and reads as "no PR", which is the fail-OPEN direction (no merged PR =
167
+ * no block), the same way a missing `gh` or an offline machine already degraded.
168
+ */
169
+ private loadPullRequestIndex;
170
+ /**
171
+ * A `main` entry synthesised WITHOUT a worktree standing on main. Carries only the two hashes
172
+ * read-stale-guard / stale-main-bash-guard actually compare (originMain vs the live tree's
173
+ * ancestry) — never merged, never conflicting, so it can only ever produce the stale-main verdict.
174
+ */
175
+ private mainOnlyStatus;
126
176
  private benignStatus;
127
177
  }
128
178
  export declare function mainSyncStatusPath(repoRoot: string): string;
129
179
  export declare function mainSyncLockPath(repoRoot: string): string;
130
- export declare function readMainSyncStatus(repoRoot: string): MainSyncStatus | null;
180
+ export declare function readMainSyncStatus(repoRoot: string, branch: string): MainSyncStatus | null;
181
+ export declare function readMainSyncStatusFile(repoRoot: string): MainSyncStatusFile | null;
131
182
  export declare function writeMainSyncStatus(repoRoot: string, status: MainSyncStatus): void;
183
+ export declare function writeMainSyncStatusFile(repoRoot: string, file: MainSyncStatusFile): void;
132
184
  export declare function readMainSyncLock(repoRoot: string): MainSyncLock | null;
133
185
  export declare function writeMainSyncLock(repoRoot: string, lock: MainSyncLock): void;
134
186
  export declare function isLockStale(lock: MainSyncLock, hangTimeoutMinutes: number, now?: number): boolean;
@@ -137,5 +189,6 @@ export declare function tryAcquireMainSyncLock(repoRoot: string, hangTimeoutMinu
137
189
  export declare function inProcessLock(now?: number, pid?: number): MainSyncLock;
138
190
  export declare function finishedLock(started: number): MainSyncLock;
139
191
  export declare function computeMainSyncStatus(repoRoot: string): MainSyncStatus;
192
+ export declare function computeAllMainSyncStatuses(repoRoot: string): MainSyncStatusFile;
140
193
  export declare function squashRecoverySteps(currentBranch: string): string[];
141
194
  export declare function stampCleanMainSyncStatus(repoRoot: string): void;
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MainSyncStatusService = exports.MainSyncLock = exports.MainSyncStatus = exports.DEFAULT_HANG_TIMEOUT_MINUTES = void 0;
3
+ exports.MainSyncStatusService = exports.MainSyncLock = exports.DEFAULT_HANG_TIMEOUT_MINUTES = exports.MAIN_SYNC_STATUS_VERSION = exports.PullRequestIndex = exports.MainSyncStatusFile = exports.MainSyncStatus = void 0;
4
4
  exports.mainSyncStatusPath = mainSyncStatusPath;
5
5
  exports.mainSyncLockPath = mainSyncLockPath;
6
6
  exports.readMainSyncStatus = readMainSyncStatus;
7
+ exports.readMainSyncStatusFile = readMainSyncStatusFile;
7
8
  exports.writeMainSyncStatus = writeMainSyncStatus;
9
+ exports.writeMainSyncStatusFile = writeMainSyncStatusFile;
8
10
  exports.readMainSyncLock = readMainSyncLock;
9
11
  exports.writeMainSyncLock = writeMainSyncLock;
10
12
  exports.isLockStale = isLockStale;
@@ -13,6 +15,7 @@ exports.tryAcquireMainSyncLock = tryAcquireMainSyncLock;
13
15
  exports.inProcessLock = inProcessLock;
14
16
  exports.finishedLock = finishedLock;
15
17
  exports.computeMainSyncStatus = computeMainSyncStatus;
18
+ exports.computeAllMainSyncStatuses = computeAllMainSyncStatuses;
16
19
  exports.squashRecoverySteps = squashRecoverySteps;
17
20
  exports.stampCleanMainSyncStatus = stampCleanMainSyncStatus;
18
21
  const tslib_1 = require("tslib");
@@ -21,8 +24,14 @@ const fs = tslib_1.__importStar(require("fs"));
21
24
  const path = tslib_1.__importStar(require("path"));
22
25
  const inversify_1 = require("inversify");
23
26
  const atomic_file_1 = require("./atomic-file");
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; } });
24
32
  const state_dir_1 = require("./state-dir");
25
33
  const to_error_1 = require("./to-error");
34
+ const worktrees_1 = require("./worktrees");
26
35
  // Shared "is my feature branch healthy relative to origin/main?" state. The SLOW signals (git fetch +
27
36
  // merge-base + same-file-overlap + a merged-PR lookup) are computed by the ai-hook-rules refresher in a
28
37
  // DETACHED background process; the feature-branch-guard only READS this cached file. pr-gate's merge
@@ -33,39 +42,6 @@ const MAIN_SYNC_STATUS_FILE = 'main-sync-status.json';
33
42
  const MAIN_SYNC_LOCK_FILE = 'main-sync.lock.json';
34
43
  const LOCK_STATE_INPROCESS = 'inprocess';
35
44
  const LOCK_STATE_FINISHED = 'finished';
36
- // Data-only (per CLAUDE.md, classes for data).
37
- class MainSyncStatus {
38
- branch;
39
- branchAlreadyMerged;
40
- mergedPr;
41
- hasForkPoint;
42
- forkPoint;
43
- originMain;
44
- featureHead;
45
- conflict;
46
- conflictFiles;
47
- timestamp;
48
- // An OPEN (not merged) PR tracking this branch, if any — '' = none or not-yet-known. Advisory.
49
- // Kept OUT of the positional constructor (a defaulted field) so existing call sites don't churn.
50
- openPr = '';
51
- // The LOCAL refs/heads/main hash — '' = main does not exist locally (fresh clone / worktree) or
52
- // could not be read. Paired with `originMain`, this is what tells the read-stale-guard whether a
53
- // checked-out `main` is behind its remote. Defaulted field for the same reason as `openPr`.
54
- localMain = '';
55
- constructor(branch, branchAlreadyMerged, mergedPr, hasForkPoint, forkPoint, originMain, featureHead, conflict, conflictFiles, timestamp) {
56
- this.branch = branch;
57
- this.branchAlreadyMerged = branchAlreadyMerged;
58
- this.mergedPr = mergedPr;
59
- this.hasForkPoint = hasForkPoint;
60
- this.forkPoint = forkPoint;
61
- this.originMain = originMain;
62
- this.featureHead = featureHead;
63
- this.conflict = conflict;
64
- this.conflictFiles = conflictFiles;
65
- this.timestamp = timestamp;
66
- }
67
- }
68
- exports.MainSyncStatus = MainSyncStatus;
69
45
  // Concurrency state machine for the detached refresher. `started` is epoch ms. `pid` is the refresher
70
46
  // process's pid (0 = unknown) — used so a KILLED refresher doesn't wedge `inprocess` for the timeout.
71
47
  class MainSyncLock {
@@ -86,9 +62,13 @@ exports.MainSyncLock = MainSyncLock;
86
62
  let MainSyncStatusService = class MainSyncStatusService {
87
63
  dotDir;
88
64
  atomicFile;
89
- constructor(dotDir = state_dir_1.dotWebpieces, atomicFile = new atomic_file_1.AtomicFile()) {
65
+ store;
66
+ worktrees;
67
+ constructor(dotDir = state_dir_1.dotWebpieces, atomicFile = new atomic_file_1.AtomicFile(), store = new main_sync_file_1.MainSyncFileStore(atomicFile), worktrees = new worktrees_1.WorktreeService()) {
90
68
  this.dotDir = dotDir;
91
69
  this.atomicFile = atomicFile;
70
+ this.store = store;
71
+ this.worktrees = worktrees;
92
72
  }
93
73
  /**
94
74
  * SHARED scope: `<primary>/.webpieces/main-sync-status.json`, one per repo.
@@ -97,14 +77,13 @@ let MainSyncStatusService = class MainSyncStatusService {
97
77
  * one `.git`, one `origin/main`, one fetch, one answer. A per-worktree copy under single-flight
98
78
  * would only ever be written for whichever worktree won the lock anyway.
99
79
  *
100
- * KNOWN CONSEQUENCE, stated plainly rather than discovered later: the file's CONTENT is keyed to
101
- * one branch (`branch`, `featureHead`, `conflict`, `conflictFiles`), and every reader fails OPEN
102
- * when the cached branch is not the branch being judged (`merged-branch-bash-guard` and
103
- * `stale-main-bash-guard` both log `stale-cross-branch-cache (fail-open)`). With several worktrees
104
- * on several branches, the losers of the lock therefore see a cross-branch cache and their guards
105
- * allow rather than block. That is the fail-SAFE direction and it is inherent to single-flight, not
106
- * to the shared path but making the entries branch-keyed (a map of branch → status, written
107
- * atomically) is the obvious follow-up.
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.
108
87
  */
109
88
  mainSyncStatusPath(repoRoot) {
110
89
  return this.dotDir.sharedFile(repoRoot, MAIN_SYNC_STATUS_FILE);
@@ -126,33 +105,41 @@ let MainSyncStatusService = class MainSyncStatusService {
126
105
  mainSyncLockPath(repoRoot) {
127
106
  return this.dotDir.sharedFile(repoRoot, MAIN_SYNC_LOCK_FILE);
128
107
  }
129
- // Pure read — any error (missing file, malformed JSON) returns null so the guard fails OPEN.
130
- readMainSyncStatus(repoRoot) {
131
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
132
- try {
133
- const statusPath = this.mainSyncStatusPath(repoRoot);
134
- if (!fs.existsSync(statusPath))
135
- return null;
136
- const raw = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
137
- const status = new MainSyncStatus(raw.branch ?? '', raw.branchAlreadyMerged ?? false, raw.mergedPr ?? '', raw.hasForkPoint ?? true, raw.forkPoint ?? null, raw.originMain ?? '', raw.featureHead ?? '', raw.conflict ?? false, raw.conflictFiles ?? [], raw.timestamp ?? '');
138
- status.openPr = raw.openPr ?? '';
139
- status.localMain = raw.localMain ?? '';
140
- return status;
141
- }
142
- catch (err) {
143
- const error = (0, to_error_1.toError)(err);
144
- void error;
145
- return null;
146
- }
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));
147
122
  }
148
123
  /**
149
- * ATOMIC write (temp file + `rename()` in the same directory). The refresher writes this file while
150
- * guards on the blocking path read it; a plain `writeFileSync` truncates first, so a reader landing
151
- * in that window gets a torn document. Now that the file is repo-wide the reader may be in another
152
- * worktree entirely, which widens the window rather than creating it. See AtomicFile.
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.
153
130
  */
154
131
  writeMainSyncStatus(repoRoot, status) {
155
- this.atomicFile.writeJsonAtomic(this.mainSyncStatusPath(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);
156
143
  }
157
144
  readMainSyncLock(repoRoot) {
158
145
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
@@ -249,16 +236,59 @@ let MainSyncStatusService = class MainSyncStatusService {
249
236
  return new MainSyncLock(LOCK_STATE_FINISHED, started, 0);
250
237
  }
251
238
  /**
252
- * The SLOW path, run only inside the detached refresher. Computes every cached signal the
253
- * feature-branch-guard needs. Never run on the hook's blocking path.
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.
254
242
  */
255
- // webpieces-disable max-lines-new-methods -- one cohesive slow-path computation
256
243
  computeMainSyncStatus(repoRoot) {
257
- const branch = this.gitBranch(repoRoot);
258
- const mergedPr = this.detectMergedPr(repoRoot, branch);
259
- const openPr = this.detectOpenPr(repoRoot, branch);
260
244
  // Best-effort network refresh; offline just means we evaluate against the last-fetched ref.
261
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
+ const mergedPr = index.mergedFor(branch);
291
+ const openPr = index.openFor(branch);
262
292
  const head = this.capture(repoRoot, 'git', ['rev-parse', 'HEAD']);
263
293
  const originMain = this.capture(repoRoot, 'git', ['rev-parse', 'origin/main']);
264
294
  const localMain = this.localMainHash(repoRoot);
@@ -273,7 +303,7 @@ let MainSyncStatusService = class MainSyncStatusService {
273
303
  }
274
304
  const forkPoint = this.capture(repoRoot, 'git', ['merge-base', 'origin/main', 'HEAD']);
275
305
  if (!forkPoint.ok || forkPoint.out === '') {
276
- const noFork = new MainSyncStatus(branch, mergedPr !== '', mergedPr, false, null, originMain.out, featureHead, false, [], new Date().toISOString());
306
+ const noFork = new main_sync_file_1.MainSyncStatus(branch, mergedPr !== '', mergedPr, false, null, originMain.out, featureHead, false, [], new Date().toISOString());
277
307
  noFork.openPr = openPr;
278
308
  noFork.localMain = localMain;
279
309
  return noFork;
@@ -281,7 +311,7 @@ let MainSyncStatusService = class MainSyncStatusService {
281
311
  const featureFiles = new Set(this.featureChangedFiles(repoRoot, forkPoint.out));
282
312
  const mainFiles = this.changedFiles(repoRoot, forkPoint.out, 'origin/main');
283
313
  const conflictFiles = mainFiles.filter((file) => featureFiles.has(file));
284
- const status = new MainSyncStatus(branch, mergedPr !== '', mergedPr, true, forkPoint.out, originMain.out, featureHead, conflictFiles.length > 0, conflictFiles, new Date().toISOString());
314
+ const status = new main_sync_file_1.MainSyncStatus(branch, mergedPr !== '', mergedPr, true, forkPoint.out, originMain.out, featureHead, conflictFiles.length > 0, conflictFiles, new Date().toISOString());
285
315
  status.openPr = openPr;
286
316
  status.localMain = localMain;
287
317
  return status;
@@ -302,7 +332,13 @@ let MainSyncStatusService = class MainSyncStatusService {
302
332
  '5. If a PR exists: open a NEW PR for the -v2 branch and close the old one.',
303
333
  ];
304
334
  }
305
- // Synchronously stamp a clean "up to date with main" status — call right after a successful merge.
335
+ /**
336
+ * Synchronously stamp a clean "up to date with main" status — call right after a successful merge.
337
+ *
338
+ * This is the one writer that takes NO lock, and it knows about exactly one branch. It therefore
339
+ * goes through writeMainSyncStatus, which MERGES its entry into the existing map; writing a whole
340
+ * document from here would wipe every other worktree's entry.
341
+ */
306
342
  stampCleanMainSyncStatus(repoRoot) {
307
343
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
308
344
  try {
@@ -311,7 +347,7 @@ let MainSyncStatusService = class MainSyncStatusService {
311
347
  const featureHead = this.capture(repoRoot, 'git', ['rev-parse', 'HEAD']);
312
348
  if (!originMain.ok || !featureHead.ok)
313
349
  return;
314
- const status = new MainSyncStatus(branch, false, '', true, originMain.out, originMain.out, featureHead.out, false, [], new Date().toISOString());
350
+ const status = new main_sync_file_1.MainSyncStatus(branch, false, '', true, originMain.out, originMain.out, featureHead.out, false, [], new Date().toISOString());
315
351
  status.localMain = this.localMainHash(repoRoot);
316
352
  this.writeMainSyncStatus(repoRoot, status);
317
353
  }
@@ -411,30 +447,44 @@ let MainSyncStatusService = class MainSyncStatusService {
411
447
  add(['ls-files', '--others', '--exclude-standard']); // untracked new files (respects .gitignore)
412
448
  return [...out];
413
449
  }
414
- // Has this feature branch already been merged into main? Reliable signal: a MERGED PR exists.
415
- detectMergedPr(repoRoot, branch) {
416
- if (!branch || branch === 'main')
417
- return '';
418
- const result = this.capture(repoRoot, 'gh', ['pr', 'list', '--head', branch, '--state', 'merged', '--json', 'number', '--jq', '.[0].number']);
419
- return result.ok ? result.out : '';
450
+ /**
451
+ * ONE repo-wide `gh pr list` giving every branch's merged/open PR — replaces the two per-branch
452
+ * `--head <branch>` queries. `--limit 200` bounds it; a branch whose only PR is older than that
453
+ * falls out of the index and reads as "no PR", which is the fail-OPEN direction (no merged PR =
454
+ * no block), the same way a missing `gh` or an offline machine already degraded.
455
+ */
456
+ loadPullRequestIndex(repoRoot) {
457
+ const result = this.capture(repoRoot, 'gh', [
458
+ 'pr', 'list', '--state', 'all', '--json', 'number,headRefName,state', '--limit', '200',
459
+ ]);
460
+ if (!result.ok || result.out === '')
461
+ return new main_sync_file_1.PullRequestIndex({}, {});
462
+ return this.store.indexPullRequests(result.out);
420
463
  }
421
- // An OPEN PR tracking this branch, if any. Best-effort/advisory.
422
- detectOpenPr(repoRoot, branch) {
423
- if (!branch || branch === 'main')
424
- return '';
425
- const result = this.capture(repoRoot, 'gh', ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number', '--jq', '.[0].number']);
426
- return result.ok ? result.out : '';
464
+ /**
465
+ * A `main` entry synthesised WITHOUT a worktree standing on main. Carries only the two hashes
466
+ * read-stale-guard / stale-main-bash-guard actually compare (originMain vs the live tree's
467
+ * ancestry) — never merged, never conflicting, so it can only ever produce the stale-main verdict.
468
+ */
469
+ mainOnlyStatus(repoRoot) {
470
+ const originMain = this.capture(repoRoot, 'git', ['rev-parse', 'origin/main']);
471
+ const localMain = this.localMainHash(repoRoot);
472
+ const status = new main_sync_file_1.MainSyncStatus('main', false, '', true, localMain === '' ? null : localMain, originMain.ok ? originMain.out : '', localMain, false, [], new Date().toISOString());
473
+ status.localMain = localMain;
474
+ return status;
427
475
  }
428
476
  // A benign status that never blocks — used when origin/main can't be resolved.
429
477
  benignStatus(branch, featureHead) {
430
- return new MainSyncStatus(branch, false, '', true, null, '', featureHead, false, [], new Date().toISOString());
478
+ return new main_sync_file_1.MainSyncStatus(branch, false, '', true, null, '', featureHead, false, [], new Date().toISOString());
431
479
  }
432
480
  };
433
481
  exports.MainSyncStatusService = MainSyncStatusService;
434
482
  exports.MainSyncStatusService = MainSyncStatusService = tslib_1.__decorate([
435
483
  (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
436
484
  tslib_1.__metadata("design:paramtypes", [state_dir_1.DotWebpieces,
437
- atomic_file_1.AtomicFile])
485
+ atomic_file_1.AtomicFile,
486
+ main_sync_file_1.MainSyncFileStore,
487
+ worktrees_1.WorktreeService])
438
488
  ], MainSyncStatusService);
439
489
  // Temporary migration delegators to MainSyncStatusService — removed once consumers inject it.
440
490
  const mainSyncSvc = new MainSyncStatusService();
@@ -443,10 +493,14 @@ function mainSyncStatusPath(repoRoot) { return mainSyncSvc.mainSyncStatusPath(re
443
493
  // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
444
494
  function mainSyncLockPath(repoRoot) { return mainSyncSvc.mainSyncLockPath(repoRoot); }
445
495
  // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
446
- function readMainSyncStatus(repoRoot) { return mainSyncSvc.readMainSyncStatus(repoRoot); }
496
+ function readMainSyncStatus(repoRoot, branch) { return mainSyncSvc.readMainSyncStatus(repoRoot, branch); }
497
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
498
+ function readMainSyncStatusFile(repoRoot) { return mainSyncSvc.readMainSyncStatusFile(repoRoot); }
447
499
  // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
448
500
  function writeMainSyncStatus(repoRoot, status) { mainSyncSvc.writeMainSyncStatus(repoRoot, status); }
449
501
  // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
502
+ function writeMainSyncStatusFile(repoRoot, file) { mainSyncSvc.writeMainSyncStatusFile(repoRoot, file); }
503
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
450
504
  function readMainSyncLock(repoRoot) { return mainSyncSvc.readMainSyncLock(repoRoot); }
451
505
  // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
452
506
  function writeMainSyncLock(repoRoot, lock) { mainSyncSvc.writeMainSyncLock(repoRoot, lock); }
@@ -463,6 +517,8 @@ function finishedLock(started) { return mainSyncSvc.finishedLock(started); }
463
517
  // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
464
518
  function computeMainSyncStatus(repoRoot) { return mainSyncSvc.computeMainSyncStatus(repoRoot); }
465
519
  // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
520
+ function computeAllMainSyncStatuses(repoRoot) { return mainSyncSvc.computeAllMainSyncStatuses(repoRoot); }
521
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
466
522
  function squashRecoverySteps(currentBranch) { return mainSyncSvc.squashRecoverySteps(currentBranch); }
467
523
  // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
468
524
  function stampCleanMainSyncStatus(repoRoot) { mainSyncSvc.stampCleanMainSyncStatus(repoRoot); }