@webpieces/rules-config 0.3.359 → 0.3.361

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.
@@ -19,6 +19,38 @@ export declare class MainSyncLock {
19
19
  pid: number;
20
20
  constructor(state: string, started: number, pid?: number);
21
21
  }
22
+ /**
23
+ * Reads/writes the main-sync cache + lock and computes the slow "is my branch healthy vs origin/main?"
24
+ * status. `@provideSingleton` so it's injectable and drawn in the rules-config DI design.
25
+ */
26
+ export declare class MainSyncStatusService {
27
+ mainSyncStatusPath(repoRoot: string): string;
28
+ mainSyncLockPath(repoRoot: string): string;
29
+ readMainSyncStatus(repoRoot: string): MainSyncStatus | null;
30
+ writeMainSyncStatus(repoRoot: string, status: MainSyncStatus): void;
31
+ readMainSyncLock(repoRoot: string): MainSyncLock | null;
32
+ writeMainSyncLock(repoRoot: string, lock: MainSyncLock): void;
33
+ isLockStale(lock: MainSyncLock, hangTimeoutMinutes: number, now?: number): boolean;
34
+ isRefreshInProgress(repoRoot: string, hangTimeoutMinutes: number, now?: number): boolean;
35
+ inProcessLock(now?: number, pid?: number): MainSyncLock;
36
+ finishedLock(started: number): MainSyncLock;
37
+ /**
38
+ * The SLOW path, run only inside the detached refresher. Computes every cached signal the
39
+ * feature-branch-guard needs. Never run on the hook's blocking path.
40
+ */
41
+ computeMainSyncStatus(repoRoot: string): MainSyncStatus;
42
+ squashRecoverySteps(currentBranch: string): string[];
43
+ stampCleanMainSyncStatus(repoRoot: string): void;
44
+ private ensureDir;
45
+ private isProcessAlive;
46
+ private capture;
47
+ private gitBranch;
48
+ private changedFiles;
49
+ private featureChangedFiles;
50
+ private detectMergedPr;
51
+ private detectOpenPr;
52
+ private benignStatus;
53
+ }
22
54
  export declare function mainSyncStatusPath(repoRoot: string): string;
23
55
  export declare function mainSyncLockPath(repoRoot: string): string;
24
56
  export declare function readMainSyncStatus(repoRoot: string): MainSyncStatus | null;
@@ -29,13 +61,6 @@ export declare function isLockStale(lock: MainSyncLock, hangTimeoutMinutes: numb
29
61
  export declare function isRefreshInProgress(repoRoot: string, hangTimeoutMinutes: number, now?: number): boolean;
30
62
  export declare function inProcessLock(now?: number, pid?: number): MainSyncLock;
31
63
  export declare function finishedLock(started: number): MainSyncLock;
32
- /**
33
- * The SLOW path, run only inside the detached refresher. Computes every cached signal the
34
- * feature-branch-guard needs: whether the branch is already merged (merged PR), whether a fork point
35
- * with origin/main still exists, and whether origin/main and this branch touched the SAME file since
36
- * the fork point (the deliberately-simple conflict heuristic — it over-blocks rather than miss a real
37
- * conflict). Never run on the hook's blocking path.
38
- */
39
64
  export declare function computeMainSyncStatus(repoRoot: string): MainSyncStatus;
40
65
  export declare function squashRecoverySteps(currentBranch: string): string[];
41
66
  export declare function stampCleanMainSyncStatus(repoRoot: string): void;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MainSyncLock = exports.MainSyncStatus = exports.DEFAULT_HANG_TIMEOUT_MINUTES = void 0;
3
+ exports.MainSyncStatusService = exports.MainSyncLock = exports.MainSyncStatus = exports.DEFAULT_HANG_TIMEOUT_MINUTES = void 0;
4
4
  exports.mainSyncStatusPath = mainSyncStatusPath;
5
5
  exports.mainSyncLockPath = mainSyncLockPath;
6
6
  exports.readMainSyncStatus = readMainSyncStatus;
@@ -18,24 +18,21 @@ const tslib_1 = require("tslib");
18
18
  const child_process_1 = require("child_process");
19
19
  const fs = tslib_1.__importStar(require("fs"));
20
20
  const path = tslib_1.__importStar(require("path"));
21
+ const di_1 = require("./di");
22
+ const inversify_1 = require("inversify");
21
23
  const constants_1 = require("./constants");
22
24
  const to_error_1 = require("./to-error");
23
- // Shared "is my feature branch healthy relative to origin/main?" state. The SLOW signals (git fetch
24
- // + merge-base + same-file-overlap + a merged-PR lookup) are computed by the ai-hook-rules refresher
25
- // in a DETACHED background process so the PreToolUse hook never blocks on the network. The
26
- // feature-branch-guard then only READS this cached file (instant). pr-gate's merge flow also writes
27
- // it synchronously after a merge so the next edit is unblocked immediately. Lives here (the shared
28
- // dep of both packages) so neither depends on the other.
29
- // How long an `inprocess` refresher lock may sit before a new refresher assumes the prior run hung
30
- // and proceeds anyway. Overridable per-rule via FeatureBranchGuardConfig.hangTimeoutMinutes.
25
+ // Shared "is my feature branch healthy relative to origin/main?" state. The SLOW signals (git fetch +
26
+ // merge-base + same-file-overlap + a merged-PR lookup) are computed by the ai-hook-rules refresher in a
27
+ // DETACHED background process; the feature-branch-guard only READS this cached file. pr-gate's merge
28
+ // flow also writes it synchronously after a merge. Lives here (the shared dep of both).
29
+ // How long an `inprocess` refresher lock may sit before a new refresher assumes the prior run hung.
31
30
  exports.DEFAULT_HANG_TIMEOUT_MINUTES = 5;
32
31
  const MAIN_SYNC_STATUS_FILE = 'main-sync-status.json';
33
32
  const MAIN_SYNC_LOCK_FILE = 'main-sync.lock.json';
34
33
  const LOCK_STATE_INPROCESS = 'inprocess';
35
34
  const LOCK_STATE_FINISHED = 'finished';
36
- // Data-only (per CLAUDE.md, classes for data). `forkPoint` is null exactly when `hasForkPoint` is
37
- // false (no merge-base with origin/main). `branchAlreadyMerged` flags that this feature branch was
38
- // already merged into main (a merged PR exists) — the "you're working on a finished branch" case.
35
+ // Data-only (per CLAUDE.md, classes for data).
39
36
  class MainSyncStatus {
40
37
  branch;
41
38
  branchAlreadyMerged;
@@ -47,10 +44,7 @@ class MainSyncStatus {
47
44
  conflict;
48
45
  conflictFiles;
49
46
  timestamp;
50
- // An OPEN (not merged) PR tracking this branch, if any — '' = none or not-yet-known. Set by the
51
- // refresher (best-effort) and read by the feature-branch-guard so a mid-work conflict block can
52
- // steer straight to the PR flow instead of the update-only flow (which would just fail-fast when a
53
- // PR exists). Advisory only — the authoritative gate is wp-start-update's own fail-fast check.
47
+ // An OPEN (not merged) PR tracking this branch, if any — '' = none or not-yet-known. Advisory.
54
48
  // Kept OUT of the positional constructor (a defaulted field) so existing call sites don't churn.
55
49
  openPr = '';
56
50
  constructor(branch, branchAlreadyMerged, mergedPr, hasForkPoint, forkPoint, originMain, featureHead, conflict, conflictFiles, timestamp) {
@@ -67,10 +61,8 @@ class MainSyncStatus {
67
61
  }
68
62
  }
69
63
  exports.MainSyncStatus = MainSyncStatus;
70
- // Concurrency state machine for the detached refresher. `started` is epoch milliseconds. `pid` is
71
- // the refresher process's pid (0 = unknown, e.g. a lock written by an older version) used so a
72
- // refresher that was KILLED before writing its finished lock (SIGKILL skips the finally) doesn't
73
- // wedge `inprocess` for the whole hangTimeout: if its pid is gone, the lock is reclaimable now.
64
+ // Concurrency state machine for the detached refresher. `started` is epoch ms. `pid` is the refresher
65
+ // process's pid (0 = unknown) used so a KILLED refresher doesn't wedge `inprocess` for the timeout.
74
66
  class MainSyncLock {
75
67
  state;
76
68
  started;
@@ -82,237 +74,247 @@ class MainSyncLock {
82
74
  }
83
75
  }
84
76
  exports.MainSyncLock = MainSyncLock;
85
- function mainSyncStatusPath(repoRoot) {
86
- return path.join(repoRoot, constants_1.WEBPIECES_TMP_DIR, MAIN_SYNC_STATUS_FILE);
87
- }
88
- function mainSyncLockPath(repoRoot) {
89
- return path.join(repoRoot, constants_1.WEBPIECES_TMP_DIR, MAIN_SYNC_LOCK_FILE);
90
- }
91
- function ensureDir(filePath) {
92
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
93
- }
94
- // Pure read — any error (missing file, malformed JSON) returns null so the guard fails OPEN
95
- // (never block an edit because the cache is unreadable).
96
- function readMainSyncStatus(repoRoot) {
97
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
98
- try {
99
- const statusPath = mainSyncStatusPath(repoRoot);
100
- if (!fs.existsSync(statusPath))
77
+ /**
78
+ * Reads/writes the main-sync cache + lock and computes the slow "is my branch healthy vs origin/main?"
79
+ * status. `@provideSingleton` so it's injectable and drawn in the rules-config DI design.
80
+ */
81
+ let MainSyncStatusService = class MainSyncStatusService {
82
+ mainSyncStatusPath(repoRoot) {
83
+ return path.join(repoRoot, constants_1.WEBPIECES_TMP_DIR, MAIN_SYNC_STATUS_FILE);
84
+ }
85
+ mainSyncLockPath(repoRoot) {
86
+ return path.join(repoRoot, constants_1.WEBPIECES_TMP_DIR, MAIN_SYNC_LOCK_FILE);
87
+ }
88
+ // Pure read — any error (missing file, malformed JSON) returns null so the guard fails OPEN.
89
+ readMainSyncStatus(repoRoot) {
90
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
91
+ try {
92
+ const statusPath = this.mainSyncStatusPath(repoRoot);
93
+ if (!fs.existsSync(statusPath))
94
+ return null;
95
+ const raw = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
96
+ 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 ?? '');
97
+ status.openPr = raw.openPr ?? '';
98
+ return status;
99
+ }
100
+ catch (err) {
101
+ const error = (0, to_error_1.toError)(err);
102
+ void error;
101
103
  return null;
102
- const raw = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
103
- 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 ?? '');
104
- status.openPr = raw.openPr ?? '';
105
- return status;
104
+ }
106
105
  }
107
- catch (err) {
108
- const error = (0, to_error_1.toError)(err);
109
- void error;
110
- return null;
106
+ writeMainSyncStatus(repoRoot, status) {
107
+ const statusPath = this.mainSyncStatusPath(repoRoot);
108
+ this.ensureDir(statusPath);
109
+ fs.writeFileSync(statusPath, JSON.stringify(status, null, 2) + '\n');
111
110
  }
112
- }
113
- function writeMainSyncStatus(repoRoot, status) {
114
- const statusPath = mainSyncStatusPath(repoRoot);
115
- ensureDir(statusPath);
116
- fs.writeFileSync(statusPath, JSON.stringify(status, null, 2) + '\n');
117
- }
118
- function readMainSyncLock(repoRoot) {
119
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
120
- try {
121
- const lockPath = mainSyncLockPath(repoRoot);
122
- if (!fs.existsSync(lockPath))
111
+ readMainSyncLock(repoRoot) {
112
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
113
+ try {
114
+ const lockPath = this.mainSyncLockPath(repoRoot);
115
+ if (!fs.existsSync(lockPath))
116
+ return null;
117
+ const raw = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
118
+ return new MainSyncLock(raw.state ?? LOCK_STATE_FINISHED, raw.started ?? 0, raw.pid ?? 0);
119
+ }
120
+ catch (err) {
121
+ const error = (0, to_error_1.toError)(err);
122
+ void error;
123
123
  return null;
124
- const raw = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
125
- return new MainSyncLock(raw.state ?? LOCK_STATE_FINISHED, raw.started ?? 0, raw.pid ?? 0);
124
+ }
126
125
  }
127
- catch (err) {
128
- const error = (0, to_error_1.toError)(err);
129
- void error;
130
- return null;
126
+ writeMainSyncLock(repoRoot, lock) {
127
+ const lockPath = this.mainSyncLockPath(repoRoot);
128
+ this.ensureDir(lockPath);
129
+ fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
131
130
  }
132
- }
133
- function writeMainSyncLock(repoRoot, lock) {
134
- const lockPath = mainSyncLockPath(repoRoot);
135
- ensureDir(lockPath);
136
- fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
137
- }
138
- // A lock is stale (the prior refresher is assumed hung) once it has been `inprocess` longer than
139
- // hangTimeoutMinutes. `now` is injectable for tests; defaults to Date.now().
140
- function isLockStale(lock, hangTimeoutMinutes, now = Date.now()) {
141
- return now - lock.started > hangTimeoutMinutes * 60 * 1000;
142
- }
143
- // Liveness probe: is `pid` still a running process? `process.kill(pid, 0)` sends no signal, it only
144
- // tests existence — ESRCH means the process is gone, EPERM means it exists but isn't ours (alive).
145
- // pid <= 0 means "unknown" (an old lock with no pid) → assume alive and fall back to staleness only.
146
- function isProcessAlive(pid) {
147
- if (pid <= 0)
148
- return true;
149
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
150
- try {
151
- process.kill(pid, 0);
152
- return true;
131
+ // A lock is stale (prior refresher assumed hung) once `inprocess` longer than hangTimeoutMinutes.
132
+ isLockStale(lock, hangTimeoutMinutes, now = Date.now()) {
133
+ return now - lock.started > hangTimeoutMinutes * 60 * 1000;
153
134
  }
154
- catch (err) {
155
- const error = (0, to_error_1.toError)(err);
156
- // ESRCH = no such process (dead); anything else (e.g. EPERM = exists, not ours) = alive.
157
- return !error.message.includes('ESRCH');
135
+ // True when another refresher is actively running and we should NOT start a second one.
136
+ isRefreshInProgress(repoRoot, hangTimeoutMinutes, now = Date.now()) {
137
+ const lock = this.readMainSyncLock(repoRoot);
138
+ if (!lock)
139
+ return false;
140
+ if (lock.state !== LOCK_STATE_INPROCESS)
141
+ return false;
142
+ if (this.isLockStale(lock, hangTimeoutMinutes, now))
143
+ return false;
144
+ return this.isProcessAlive(lock.pid);
158
145
  }
159
- }
160
- // True when another refresher is actively running and we should NOT start a second one. A finished
161
- // lock, a missing lock, a stale (hung) inprocess lock, OR an inprocess lock whose refresher pid is
162
- // already dead (it was killed before writing its finished lock) all return false — so a killed
163
- // refresher never wedges refreshes for the full hangTimeout.
164
- function isRefreshInProgress(repoRoot, hangTimeoutMinutes, now = Date.now()) {
165
- const lock = readMainSyncLock(repoRoot);
166
- if (!lock)
167
- return false;
168
- if (lock.state !== LOCK_STATE_INPROCESS)
169
- return false;
170
- if (isLockStale(lock, hangTimeoutMinutes, now))
171
- return false;
172
- return isProcessAlive(lock.pid);
173
- }
174
- function inProcessLock(now = Date.now(), pid = process.pid) {
175
- return new MainSyncLock(LOCK_STATE_INPROCESS, now, pid);
176
- }
177
- function finishedLock(started) {
178
- return new MainSyncLock(LOCK_STATE_FINISHED, started, 0);
179
- }
180
- // Run a command capturing trimmed stdout; ok=false on spawn failure or non-zero exit.
181
- function capture(repoRoot, cmd, args) {
182
- const result = (0, child_process_1.spawnSync)(cmd, args, { cwd: repoRoot, encoding: 'utf8' });
183
- if (result.status !== 0 || typeof result.stdout !== 'string')
184
- return { ok: false, out: '' };
185
- return { ok: true, out: result.stdout.trim() };
186
- }
187
- // The actual checked-out branch in repoRoot — cwd-correct so the cache's `branch` label always
188
- // matches what the feature-branch-guard compares against (its own `git rev-parse` in the workspace).
189
- function gitBranch(repoRoot) {
190
- const result = capture(repoRoot, 'git', ['rev-parse', '--abbrev-ref', 'HEAD']);
191
- return result.ok ? result.out : '';
192
- }
193
- function changedFiles(repoRoot, base, head) {
194
- const result = capture(repoRoot, 'git', ['diff', '--name-only', base, head]);
195
- if (!result.ok || result.out === '')
196
- return [];
197
- return result.out.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
198
- }
199
- // Every file this feature branch has touched since the fork point — committed AND still in the
200
- // working tree (staged / unstaged / untracked). The committed-only `git diff forkPoint..HEAD` was
201
- // BLIND to uncommitted edits: for most of an editing session the files you are actively changing are
202
- // not yet in HEAD, so the overlap with main's changes was empty and conflict=false even when
203
- // origin/main had already moved onto those same files. Unioning in the working-tree changes makes the
204
- // conflict visible WHILE editing, so the guard can force an early merge instead of a painful late one.
205
- function featureChangedFiles(repoRoot, forkPoint) {
206
- const out = new Set();
207
- const add = (args) => {
208
- const r = capture(repoRoot, 'git', args);
209
- if (!r.ok || r.out === '')
210
- return;
211
- for (const line of r.out.split('\n')) {
212
- const f = line.trim();
213
- if (f.length > 0)
214
- out.add(f);
146
+ inProcessLock(now = Date.now(), pid = process.pid) {
147
+ return new MainSyncLock(LOCK_STATE_INPROCESS, now, pid);
148
+ }
149
+ finishedLock(started) {
150
+ return new MainSyncLock(LOCK_STATE_FINISHED, started, 0);
151
+ }
152
+ /**
153
+ * The SLOW path, run only inside the detached refresher. Computes every cached signal the
154
+ * feature-branch-guard needs. Never run on the hook's blocking path.
155
+ */
156
+ // webpieces-disable max-lines-new-methods -- one cohesive slow-path computation
157
+ computeMainSyncStatus(repoRoot) {
158
+ const branch = this.gitBranch(repoRoot);
159
+ const mergedPr = this.detectMergedPr(repoRoot, branch);
160
+ const openPr = this.detectOpenPr(repoRoot, branch);
161
+ // Best-effort network refresh; offline just means we evaluate against the last-fetched ref.
162
+ (0, child_process_1.spawnSync)('git', ['fetch', 'origin', 'main'], { cwd: repoRoot, stdio: 'ignore' });
163
+ const head = this.capture(repoRoot, 'git', ['rev-parse', 'HEAD']);
164
+ const originMain = this.capture(repoRoot, 'git', ['rev-parse', 'origin/main']);
165
+ const featureHead = head.ok ? head.out : '';
166
+ if (!head.ok || !originMain.ok) {
167
+ const status = this.benignStatus(branch, featureHead);
168
+ status.branchAlreadyMerged = mergedPr !== '';
169
+ status.mergedPr = mergedPr;
170
+ status.openPr = openPr;
171
+ return status;
215
172
  }
216
- };
217
- add(['diff', '--name-only', forkPoint, 'HEAD']); // committed since the fork point
218
- add(['diff', '--name-only', 'HEAD']); // unstaged working-tree edits
219
- add(['diff', '--name-only', '--cached', 'HEAD']); // staged edits
220
- add(['ls-files', '--others', '--exclude-standard']); // untracked new files (respects .gitignore)
221
- return [...out];
222
- }
223
- // Has this feature branch already been merged into main? Reliable signal: a MERGED PR exists for the
224
- // branch. Best-effort if gh is missing/unauthenticated we just report not-merged (false).
225
- function detectMergedPr(repoRoot, branch) {
226
- if (!branch || branch === 'main')
227
- return '';
228
- const result = capture(repoRoot, 'gh', ['pr', 'list', '--head', branch, '--state', 'merged', '--json', 'number', '--jq', '.[0].number']);
229
- return result.ok ? result.out : '';
230
- }
231
- // An OPEN PR tracking this branch, if any. Best-effort — this is ADVISORY (it only lets the guard's
232
- // conflict block steer to the PR flow early), so an unreachable gh degrades to '' here. The HARD gate
233
- // that must never guess is wp-start-update's own openPrForBranch, which fails fast instead.
234
- function detectOpenPr(repoRoot, branch) {
235
- if (!branch || branch === 'main')
236
- return '';
237
- const result = capture(repoRoot, 'gh', ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number', '--jq', '.[0].number']);
238
- return result.ok ? result.out : '';
239
- }
240
- // A benign status that never blocks — used when origin/main can't be resolved (no remote yet,
241
- // offline before first fetch). hasForkPoint=true + conflict=false so the guard allows the edit.
242
- function benignStatus(branch, featureHead) {
243
- return new MainSyncStatus(branch, false, '', true, null, '', featureHead, false, [], new Date().toISOString());
244
- }
245
- /**
246
- * The SLOW path, run only inside the detached refresher. Computes every cached signal the
247
- * feature-branch-guard needs: whether the branch is already merged (merged PR), whether a fork point
248
- * with origin/main still exists, and whether origin/main and this branch touched the SAME file since
249
- * the fork point (the deliberately-simple conflict heuristic — it over-blocks rather than miss a real
250
- * conflict). Never run on the hook's blocking path.
251
- */
252
- function computeMainSyncStatus(repoRoot) {
253
- const branch = gitBranch(repoRoot);
254
- const mergedPr = detectMergedPr(repoRoot, branch);
255
- // Advisory: lets the guard's conflict block steer to the PR flow early when a PR is already open.
256
- const openPr = detectOpenPr(repoRoot, branch);
257
- // Best-effort network refresh; offline just means we evaluate against the last-fetched ref.
258
- (0, child_process_1.spawnSync)('git', ['fetch', 'origin', 'main'], { cwd: repoRoot, stdio: 'ignore' });
259
- const head = capture(repoRoot, 'git', ['rev-parse', 'HEAD']);
260
- const originMain = capture(repoRoot, 'git', ['rev-parse', 'origin/main']);
261
- const featureHead = head.ok ? head.out : '';
262
- if (!head.ok || !originMain.ok) {
263
- const status = benignStatus(branch, featureHead);
264
- status.branchAlreadyMerged = mergedPr !== '';
265
- status.mergedPr = mergedPr;
173
+ const forkPoint = this.capture(repoRoot, 'git', ['merge-base', 'origin/main', 'HEAD']);
174
+ if (!forkPoint.ok || forkPoint.out === '') {
175
+ const noFork = new MainSyncStatus(branch, mergedPr !== '', mergedPr, false, null, originMain.out, featureHead, false, [], new Date().toISOString());
176
+ noFork.openPr = openPr;
177
+ return noFork;
178
+ }
179
+ const featureFiles = new Set(this.featureChangedFiles(repoRoot, forkPoint.out));
180
+ const mainFiles = this.changedFiles(repoRoot, forkPoint.out, 'origin/main');
181
+ const conflictFiles = mainFiles.filter((file) => featureFiles.has(file));
182
+ const status = new MainSyncStatus(branch, mergedPr !== '', mergedPr, true, forkPoint.out, originMain.out, featureHead, conflictFiles.length > 0, conflictFiles, new Date().toISOString());
266
183
  status.openPr = openPr;
267
184
  return status;
268
185
  }
269
- const forkPoint = capture(repoRoot, 'git', ['merge-base', 'origin/main', 'HEAD']);
270
- if (!forkPoint.ok || forkPoint.out === '') {
271
- // No common ancestor — main was merged into the branch. Force the human to squash.
272
- const noFork = new MainSyncStatus(branch, mergedPr !== '', mergedPr, false, null, originMain.out, featureHead, false, [], new Date().toISOString());
273
- noFork.openPr = openPr;
274
- return noFork;
186
+ // The recovery steps when there is no fork point with origin/main (a bad merge of main into branch).
187
+ squashRecoverySteps(currentBranch) {
188
+ return [
189
+ '1. Fetch latest main: git fetch origin main',
190
+ `2. New branch off origin/main: git checkout -b ${currentBranch}-v2 origin/main`,
191
+ `3. Squash-merge old branch: git merge --squash ${currentBranch}`,
192
+ `4. Commit the squash: git add -A && git commit -m "Squashed from ${currentBranch}"`,
193
+ '5. If a PR exists: open a NEW PR for the -v2 branch and close the old one.',
194
+ ];
275
195
  }
276
- const featureFiles = new Set(featureChangedFiles(repoRoot, forkPoint.out));
277
- const mainFiles = changedFiles(repoRoot, forkPoint.out, 'origin/main');
278
- const conflictFiles = mainFiles.filter((file) => featureFiles.has(file));
279
- const status = new MainSyncStatus(branch, mergedPr !== '', mergedPr, true, forkPoint.out, originMain.out, featureHead, conflictFiles.length > 0, conflictFiles, new Date().toISOString());
280
- status.openPr = openPr;
281
- return status;
282
- }
283
- // The recovery steps when there is no fork point with origin/main (someone merged main into the
284
- // branch, so a clean squash-merge is impossible). Shared so the feature-branch-guard and pr-gate's
285
- // findForkPoint check present the SAME instructions. The human must redo the work on a fresh branch —
286
- // deliberately painful so the bad merge gets noticed and reported.
287
- function squashRecoverySteps(currentBranch) {
288
- // Branch off origin/main directly (never `git checkout main`) so these steps are correct on the
289
- // primary repo AND inside a linked worktree, where main is checked out elsewhere and cannot be
290
- // checked out again.
291
- return [
292
- '1. Fetch latest main: git fetch origin main',
293
- `2. New branch off origin/main: git checkout -b ${currentBranch}-v2 origin/main`,
294
- `3. Squash-merge old branch: git merge --squash ${currentBranch}`,
295
- `4. Commit the squash: git add -A && git commit -m "Squashed from ${currentBranch}"`,
296
- '5. If a PR exists: open a NEW PR for the -v2 branch and close the old one.',
297
- ];
298
- }
299
- // Synchronously stamp a clean "up to date with main" status — call right after a successful merge
300
- // (the branch now contains origin/main). Unblocks the next edit immediately without waiting for the
301
- // async refresher. Best-effort: a git failure is swallowed (the refresher will recompute later).
302
- function stampCleanMainSyncStatus(repoRoot) {
303
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
304
- try {
305
- const branch = gitBranch(repoRoot);
306
- const originMain = capture(repoRoot, 'git', ['rev-parse', 'origin/main']);
307
- const featureHead = capture(repoRoot, 'git', ['rev-parse', 'HEAD']);
308
- if (!originMain.ok || !featureHead.ok)
309
- return;
310
- const status = new MainSyncStatus(branch, false, '', true, originMain.out, originMain.out, featureHead.out, false, [], new Date().toISOString());
311
- writeMainSyncStatus(repoRoot, status);
196
+ // Synchronously stamp a clean "up to date with main" status — call right after a successful merge.
197
+ stampCleanMainSyncStatus(repoRoot) {
198
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
199
+ try {
200
+ const branch = this.gitBranch(repoRoot);
201
+ const originMain = this.capture(repoRoot, 'git', ['rev-parse', 'origin/main']);
202
+ const featureHead = this.capture(repoRoot, 'git', ['rev-parse', 'HEAD']);
203
+ if (!originMain.ok || !featureHead.ok)
204
+ return;
205
+ const status = new MainSyncStatus(branch, false, '', true, originMain.out, originMain.out, featureHead.out, false, [], new Date().toISOString());
206
+ this.writeMainSyncStatus(repoRoot, status);
207
+ }
208
+ catch (err) {
209
+ const error = (0, to_error_1.toError)(err);
210
+ void error;
211
+ }
312
212
  }
313
- catch (err) {
314
- const error = (0, to_error_1.toError)(err);
315
- void error;
213
+ ensureDir(filePath) {
214
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
316
215
  }
317
- }
216
+ // Liveness probe: is `pid` still a running process? pid <= 0 (unknown) → assume alive.
217
+ isProcessAlive(pid) {
218
+ if (pid <= 0)
219
+ return true;
220
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
221
+ try {
222
+ process.kill(pid, 0);
223
+ return true;
224
+ }
225
+ catch (err) {
226
+ const error = (0, to_error_1.toError)(err);
227
+ return !error.message.includes('ESRCH');
228
+ }
229
+ }
230
+ // Run a command capturing trimmed stdout; ok=false on spawn failure or non-zero exit.
231
+ capture(repoRoot, cmd, args) {
232
+ const result = (0, child_process_1.spawnSync)(cmd, args, { cwd: repoRoot, encoding: 'utf8' });
233
+ if (result.status !== 0 || typeof result.stdout !== 'string')
234
+ return { ok: false, out: '' };
235
+ return { ok: true, out: result.stdout.trim() };
236
+ }
237
+ // The actual checked-out branch in repoRoot — cwd-correct so the cache's `branch` label matches.
238
+ gitBranch(repoRoot) {
239
+ const result = this.capture(repoRoot, 'git', ['rev-parse', '--abbrev-ref', 'HEAD']);
240
+ return result.ok ? result.out : '';
241
+ }
242
+ changedFiles(repoRoot, base, head) {
243
+ const result = this.capture(repoRoot, 'git', ['diff', '--name-only', base, head]);
244
+ if (!result.ok || result.out === '')
245
+ return [];
246
+ return result.out.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
247
+ }
248
+ // Every file this feature branch has touched since the fork point — committed AND still in the
249
+ // working tree (staged / unstaged / untracked), so a conflict is visible WHILE editing.
250
+ featureChangedFiles(repoRoot, forkPoint) {
251
+ const out = new Set();
252
+ const add = (args) => {
253
+ const r = this.capture(repoRoot, 'git', args);
254
+ if (!r.ok || r.out === '')
255
+ return;
256
+ for (const line of r.out.split('\n')) {
257
+ const f = line.trim();
258
+ if (f.length > 0)
259
+ out.add(f);
260
+ }
261
+ };
262
+ add(['diff', '--name-only', forkPoint, 'HEAD']); // committed since the fork point
263
+ add(['diff', '--name-only', 'HEAD']); // unstaged working-tree edits
264
+ add(['diff', '--name-only', '--cached', 'HEAD']); // staged edits
265
+ add(['ls-files', '--others', '--exclude-standard']); // untracked new files (respects .gitignore)
266
+ return [...out];
267
+ }
268
+ // Has this feature branch already been merged into main? Reliable signal: a MERGED PR exists.
269
+ detectMergedPr(repoRoot, branch) {
270
+ if (!branch || branch === 'main')
271
+ return '';
272
+ const result = this.capture(repoRoot, 'gh', ['pr', 'list', '--head', branch, '--state', 'merged', '--json', 'number', '--jq', '.[0].number']);
273
+ return result.ok ? result.out : '';
274
+ }
275
+ // An OPEN PR tracking this branch, if any. Best-effort/advisory.
276
+ detectOpenPr(repoRoot, branch) {
277
+ if (!branch || branch === 'main')
278
+ return '';
279
+ const result = this.capture(repoRoot, 'gh', ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number', '--jq', '.[0].number']);
280
+ return result.ok ? result.out : '';
281
+ }
282
+ // A benign status that never blocks — used when origin/main can't be resolved.
283
+ benignStatus(branch, featureHead) {
284
+ return new MainSyncStatus(branch, false, '', true, null, '', featureHead, false, [], new Date().toISOString());
285
+ }
286
+ };
287
+ exports.MainSyncStatusService = MainSyncStatusService;
288
+ exports.MainSyncStatusService = MainSyncStatusService = tslib_1.__decorate([
289
+ (0, di_1.provideSingleton)(),
290
+ (0, inversify_1.injectable)()
291
+ ], MainSyncStatusService);
292
+ // Temporary migration delegators to MainSyncStatusService — removed once consumers inject it.
293
+ const mainSyncSvc = new MainSyncStatusService();
294
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
295
+ function mainSyncStatusPath(repoRoot) { return mainSyncSvc.mainSyncStatusPath(repoRoot); }
296
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
297
+ function mainSyncLockPath(repoRoot) { return mainSyncSvc.mainSyncLockPath(repoRoot); }
298
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
299
+ function readMainSyncStatus(repoRoot) { return mainSyncSvc.readMainSyncStatus(repoRoot); }
300
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
301
+ function writeMainSyncStatus(repoRoot, status) { mainSyncSvc.writeMainSyncStatus(repoRoot, status); }
302
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
303
+ function readMainSyncLock(repoRoot) { return mainSyncSvc.readMainSyncLock(repoRoot); }
304
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
305
+ function writeMainSyncLock(repoRoot, lock) { mainSyncSvc.writeMainSyncLock(repoRoot, lock); }
306
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
307
+ function isLockStale(lock, hangTimeoutMinutes, now = Date.now()) { return mainSyncSvc.isLockStale(lock, hangTimeoutMinutes, now); }
308
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
309
+ function isRefreshInProgress(repoRoot, hangTimeoutMinutes, now = Date.now()) { return mainSyncSvc.isRefreshInProgress(repoRoot, hangTimeoutMinutes, now); }
310
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
311
+ function inProcessLock(now = Date.now(), pid = process.pid) { return mainSyncSvc.inProcessLock(now, pid); }
312
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
313
+ function finishedLock(started) { return mainSyncSvc.finishedLock(started); }
314
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
315
+ function computeMainSyncStatus(repoRoot) { return mainSyncSvc.computeMainSyncStatus(repoRoot); }
316
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
317
+ function squashRecoverySteps(currentBranch) { return mainSyncSvc.squashRecoverySteps(currentBranch); }
318
+ // webpieces-disable no-function-outside-class -- temporary back-compat delegator to MainSyncStatusService; removed once consumers inject it
319
+ function stampCleanMainSyncStatus(repoRoot) { mainSyncSvc.stampCleanMainSyncStatus(repoRoot); }
318
320
  //# sourceMappingURL=main-sync-status.js.map