@webpieces/rules-config 0.4.517 → 0.4.519

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.
@@ -0,0 +1,131 @@
1
+ import { StateDirMigrator } from './state-dir-migration';
2
+ export declare const WORKTREE_STATE_DIR = "worktrees";
3
+ /**
4
+ * Data-only carrier for the two paths git is asked for. Per CLAUDE.md: classes for data.
5
+ *
6
+ * `gitDir` is the PER-WORKTREE git dir (`<primary>/.git/worktrees/<name>` in a linked worktree,
7
+ * `<primary>/.git` in the primary clone). `commonDir` is the SHARED one — always `<primary>/.git`.
8
+ * They differ if and only if this is a linked worktree; that is git's own canonical test.
9
+ */
10
+ export declare class GitDirs {
11
+ readonly gitDir: string;
12
+ readonly commonDir: string;
13
+ constructor(gitDir: string, commonDir: string);
14
+ get isLinkedWorktree(): boolean;
15
+ }
16
+ /**
17
+ * WHERE a piece of `.webpieces/` state belongs — the ONE resolver every reader and writer must go
18
+ * through, with TWO named methods because there are exactly two answers and a call site must DECLARE
19
+ * which one it means.
20
+ *
21
+ * dotWebpieces.shared(dir) → <primary>/.webpieces (repo-wide facts)
22
+ * dotWebpieces.local(dir) → <primary>/.webpieces/worktrees/<name> (this worktree only)
23
+ * → <primary>/.webpieces (…in the primary clone)
24
+ *
25
+ * ─── The bug ───────────────────────────────────────────────────────────────────────────────────────
26
+ * `.webpieces/` is gitignored, and it was anchored at the directory holding webpieces.config.json —
27
+ * which in a linked worktree is the WORKTREE. So a repo with seven worktrees had SEVEN independent
28
+ * copies of files that describe the WHOLE REPO. `merged-branches.json` holds verdicts for every branch
29
+ * AND every worktree in the repo; N copies is N divergent truths, and the guards read them as fact.
30
+ * Observed in the field: branch-creation-guard asserted "8 parked local branches" while
31
+ * `git branch --list` showed ONE — it was reading a cache written before deletions performed from a
32
+ * DIFFERENT worktree — and it then blocked a legitimate `git worktree add` on that fiction.
33
+ *
34
+ * ─── Why two explicit methods and not a symlink ────────────────────────────────────────────────────
35
+ * A `<worktree>/.webpieces` → `<primary>/…/worktrees/<name>` symlink would have left every call site
36
+ * untouched, which is seductive and wrong. Nothing hooks `git worktree add`, so the link needs LAZY
37
+ * creation that also has to handle "link already exists", "link points somewhere else", and "a real
38
+ * directory is already there" — invisible filesystem magic with a Windows failure mode. Worse, the safe
39
+ * way to write a shared file under concurrency is temp-file-then-`rename()`, and `rename(2)` acts on
40
+ * the PATH, not the link: it REPLACES a symlink with a real file. That silently works for one writer
41
+ * and diverges for everyone else — the exact bug being fixed, but invisible. An explicit call is
42
+ * greppable, testable, and forces each site to say which scope it means.
43
+ *
44
+ * ─── Which scope is which (the scope assignment is deliberate, not incidental) ─────────────────────
45
+ * shared():
46
+ * • merged-branches.json — verdicts for every branch and worktree in the repo. Atomically written.
47
+ * • main-sync-status.json AND main-sync.lock.json — there is one `main` and one `.git`, so there is
48
+ * one refresher. A lock inside a per-worktree directory locks nothing.
49
+ * local():
50
+ * • hooks/*.log, INCLUDING branch-mutations.log. A shared append-only log genuinely corrupts:
51
+ * `O_APPEND` writes are indivisible only under PIPE_BUF, which is 512 bytes on macOS, and a
52
+ * `recover=git worktree add -b <branch> <abs-path> <tag>` line with real paths exceeds that. A
53
+ * per-worktree log has exactly ONE writer and cannot tear, and under this layout it already
54
+ * survives the worktree's deletion — recovery is one glob over
55
+ * `<primary>/.webpieces/worktrees/*/hooks/branch-mutations.log`.
56
+ * • merge-info/staged|merged/<branch>/ and its index.json, pr-review/<branch>/, instruct-ai/, and
57
+ * every other per-tree scratch file.
58
+ *
59
+ * ─── The boundary that must not be blurred ─────────────────────────────────────────────────────────
60
+ * ONLY the gitignored `.webpieces/` STATE relocates. `webpieces.config.json` is TRACKED IN GIT and is
61
+ * therefore part of the BRANCH: a branch may legitimately change its own rules and that must keep
62
+ * working. Config resolution is untouched — still per-worktree, via findConfigFile /
63
+ * RepoRootFinder.resolveRepoRoot. Nothing in this class reads or moves config.
64
+ *
65
+ * ─── Why `--git-dir` / `--git-common-dir`, and not one of the existing services ────────────────────
66
+ * They are git's own answers, from any subdirectory, in one cheap local call, with no `.git`-file
67
+ * parsing by hand (which gets `--separate-git-dir` and submodules wrong). Two existing mechanisms do
68
+ * distinguish primary from linked, and neither is the right authority here:
69
+ * • `WorktreeService` answers "what worktrees exist and what do they hold" — a repo-wide ENUMERATION
70
+ * (`git worktree list --porcelain`) for the caps and the reaper. Using it for a path lookup would
71
+ * run an enumeration on the hook's blocking path for every state access, and it fails SOFT to `[]`,
72
+ * which here would read as "there is no primary clone" on exactly the degraded repo where the
73
+ * answer matters most. It also does not expose the worktree's git NAME, which is the namespace key.
74
+ * • `EffectiveTreeResolver` (#524) answers "which tree does this COMMAND act on" — it takes a command
75
+ * string and is a policy input to the bash guards, not a filesystem-path resolver.
76
+ * Both remain authoritative for their own questions. This asks the narrowest one — two path strings —
77
+ * and lives in rules-config, UNDER both, which is where a primitive that pr-gate, ai-hook-rules and
78
+ * code-rules all need has to sit.
79
+ *
80
+ * Fails CLOSED to the pre-change behaviour: when git cannot answer, every path here collapses to
81
+ * `<startDir-root>/.webpieces` exactly as before. Degrading to merely-suboptimal beats throwing on a
82
+ * hook's blocking path.
83
+ */
84
+ export declare class DotWebpieces {
85
+ private readonly migrator;
86
+ private readonly gitDirsByRoot;
87
+ private readonly migrated;
88
+ constructor(migrator?: StateDirMigrator);
89
+ /**
90
+ * REPO-WIDE state: `<primary>/.webpieces`. Use ONLY for facts about the repo rather than about one
91
+ * worktree — today that is merged-branches.json and the main-sync status + lock. Identical from
92
+ * every worktree, and never behind an indirection, so an atomic `rename()` into it is safe.
93
+ */
94
+ shared(startDir: string): string;
95
+ /** A path beneath the repo-wide state dir. */
96
+ sharedFile(startDir: string, ...segments: string[]): string;
97
+ /**
98
+ * THIS WORKTREE's private state: `<primary>/.webpieces/worktrees/<name>` for a linked worktree, and
99
+ * `<primary>/.webpieces` for the primary clone, which keeps its state exactly where it has always
100
+ * been. Fully isolated — two worktrees never write the same path, so nothing here needs a lock.
101
+ *
102
+ * The first call for a linked worktree also MIGRATES a legacy real `<worktree>/.webpieces/`
103
+ * directory into the namespace, so in-flight merge / pr-review state written under the old scheme
104
+ * (or by an older PUBLISHED build during the transition) is picked up rather than orphaned.
105
+ */
106
+ local(startDir: string): string;
107
+ /** A path beneath this worktree's private state dir. */
108
+ localFile(startDir: string, ...segments: string[]): string;
109
+ /** True when `startDir` sits in a LINKED worktree rather than the primary clone. */
110
+ isLinkedWorktree(startDir: string): boolean;
111
+ /**
112
+ * git's own name for this linked worktree — the basename of `<primary>/.git/worktrees/<name>`, and
113
+ * the namespace key under `worktrees/`. Empty for the primary clone. git's name rather than the
114
+ * directory's basename, so two worktrees checked out into same-named directories under different
115
+ * parents cannot collide.
116
+ */
117
+ worktreeName(startDir: string): string;
118
+ /** The primary clone's root, from any worktree. Falls back to git's toplevel-less best guess. */
119
+ primaryRoot(startDir: string): string;
120
+ /**
121
+ * The PRE-change location, `<treeRoot>/.webpieces` — what every call site used to compute. Public
122
+ * because the migrator and its specs must be able to name the thing being migrated FROM, and
123
+ * because the transition-window fallback readers need it.
124
+ */
125
+ legacyDir(treeRoot: string): string;
126
+ private migrateOnce;
127
+ private gitDirs;
128
+ private gitToplevel;
129
+ private revParse;
130
+ }
131
+ export declare const dotWebpieces: DotWebpieces;
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.dotWebpieces = exports.DotWebpieces = exports.GitDirs = exports.WORKTREE_STATE_DIR = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const child_process_1 = require("child_process");
6
+ const fs = tslib_1.__importStar(require("fs"));
7
+ const path = tslib_1.__importStar(require("path"));
8
+ const inversify_1 = require("inversify");
9
+ const constants_1 = require("./constants");
10
+ const state_dir_migration_1 = require("./state-dir-migration");
11
+ // The per-worktree namespace inside the primary clone's `.webpieces/`. A LINKED worktree's local state
12
+ // lives at `<primary>/.webpieces/worktrees/<worktreeName>/`; the primary clone keeps using
13
+ // `<primary>/.webpieces/` directly, exactly as it always has.
14
+ exports.WORKTREE_STATE_DIR = 'worktrees';
15
+ // git prints the shared git dir as `<primary>/.git` for a conventional clone. Anything else (a bare
16
+ // repo, `--separate-git-dir`) is a layout we decline to derive a working tree from.
17
+ const GIT_DIR_NAME = '.git';
18
+ /**
19
+ * Data-only carrier for the two paths git is asked for. Per CLAUDE.md: classes for data.
20
+ *
21
+ * `gitDir` is the PER-WORKTREE git dir (`<primary>/.git/worktrees/<name>` in a linked worktree,
22
+ * `<primary>/.git` in the primary clone). `commonDir` is the SHARED one — always `<primary>/.git`.
23
+ * They differ if and only if this is a linked worktree; that is git's own canonical test.
24
+ */
25
+ class GitDirs {
26
+ gitDir;
27
+ commonDir;
28
+ constructor(gitDir, commonDir) {
29
+ this.gitDir = gitDir;
30
+ this.commonDir = commonDir;
31
+ }
32
+ get isLinkedWorktree() {
33
+ return path.resolve(this.gitDir) !== path.resolve(this.commonDir);
34
+ }
35
+ }
36
+ exports.GitDirs = GitDirs;
37
+ /**
38
+ * WHERE a piece of `.webpieces/` state belongs — the ONE resolver every reader and writer must go
39
+ * through, with TWO named methods because there are exactly two answers and a call site must DECLARE
40
+ * which one it means.
41
+ *
42
+ * dotWebpieces.shared(dir) → <primary>/.webpieces (repo-wide facts)
43
+ * dotWebpieces.local(dir) → <primary>/.webpieces/worktrees/<name> (this worktree only)
44
+ * → <primary>/.webpieces (…in the primary clone)
45
+ *
46
+ * ─── The bug ───────────────────────────────────────────────────────────────────────────────────────
47
+ * `.webpieces/` is gitignored, and it was anchored at the directory holding webpieces.config.json —
48
+ * which in a linked worktree is the WORKTREE. So a repo with seven worktrees had SEVEN independent
49
+ * copies of files that describe the WHOLE REPO. `merged-branches.json` holds verdicts for every branch
50
+ * AND every worktree in the repo; N copies is N divergent truths, and the guards read them as fact.
51
+ * Observed in the field: branch-creation-guard asserted "8 parked local branches" while
52
+ * `git branch --list` showed ONE — it was reading a cache written before deletions performed from a
53
+ * DIFFERENT worktree — and it then blocked a legitimate `git worktree add` on that fiction.
54
+ *
55
+ * ─── Why two explicit methods and not a symlink ────────────────────────────────────────────────────
56
+ * A `<worktree>/.webpieces` → `<primary>/…/worktrees/<name>` symlink would have left every call site
57
+ * untouched, which is seductive and wrong. Nothing hooks `git worktree add`, so the link needs LAZY
58
+ * creation that also has to handle "link already exists", "link points somewhere else", and "a real
59
+ * directory is already there" — invisible filesystem magic with a Windows failure mode. Worse, the safe
60
+ * way to write a shared file under concurrency is temp-file-then-`rename()`, and `rename(2)` acts on
61
+ * the PATH, not the link: it REPLACES a symlink with a real file. That silently works for one writer
62
+ * and diverges for everyone else — the exact bug being fixed, but invisible. An explicit call is
63
+ * greppable, testable, and forces each site to say which scope it means.
64
+ *
65
+ * ─── Which scope is which (the scope assignment is deliberate, not incidental) ─────────────────────
66
+ * shared():
67
+ * • merged-branches.json — verdicts for every branch and worktree in the repo. Atomically written.
68
+ * • main-sync-status.json AND main-sync.lock.json — there is one `main` and one `.git`, so there is
69
+ * one refresher. A lock inside a per-worktree directory locks nothing.
70
+ * local():
71
+ * • hooks/*.log, INCLUDING branch-mutations.log. A shared append-only log genuinely corrupts:
72
+ * `O_APPEND` writes are indivisible only under PIPE_BUF, which is 512 bytes on macOS, and a
73
+ * `recover=git worktree add -b <branch> <abs-path> <tag>` line with real paths exceeds that. A
74
+ * per-worktree log has exactly ONE writer and cannot tear, and under this layout it already
75
+ * survives the worktree's deletion — recovery is one glob over
76
+ * `<primary>/.webpieces/worktrees/*/hooks/branch-mutations.log`.
77
+ * • merge-info/staged|merged/<branch>/ and its index.json, pr-review/<branch>/, instruct-ai/, and
78
+ * every other per-tree scratch file.
79
+ *
80
+ * ─── The boundary that must not be blurred ─────────────────────────────────────────────────────────
81
+ * ONLY the gitignored `.webpieces/` STATE relocates. `webpieces.config.json` is TRACKED IN GIT and is
82
+ * therefore part of the BRANCH: a branch may legitimately change its own rules and that must keep
83
+ * working. Config resolution is untouched — still per-worktree, via findConfigFile /
84
+ * RepoRootFinder.resolveRepoRoot. Nothing in this class reads or moves config.
85
+ *
86
+ * ─── Why `--git-dir` / `--git-common-dir`, and not one of the existing services ────────────────────
87
+ * They are git's own answers, from any subdirectory, in one cheap local call, with no `.git`-file
88
+ * parsing by hand (which gets `--separate-git-dir` and submodules wrong). Two existing mechanisms do
89
+ * distinguish primary from linked, and neither is the right authority here:
90
+ * • `WorktreeService` answers "what worktrees exist and what do they hold" — a repo-wide ENUMERATION
91
+ * (`git worktree list --porcelain`) for the caps and the reaper. Using it for a path lookup would
92
+ * run an enumeration on the hook's blocking path for every state access, and it fails SOFT to `[]`,
93
+ * which here would read as "there is no primary clone" on exactly the degraded repo where the
94
+ * answer matters most. It also does not expose the worktree's git NAME, which is the namespace key.
95
+ * • `EffectiveTreeResolver` (#524) answers "which tree does this COMMAND act on" — it takes a command
96
+ * string and is a policy input to the bash guards, not a filesystem-path resolver.
97
+ * Both remain authoritative for their own questions. This asks the narrowest one — two path strings —
98
+ * and lives in rules-config, UNDER both, which is where a primitive that pr-gate, ai-hook-rules and
99
+ * code-rules all need has to sit.
100
+ *
101
+ * Fails CLOSED to the pre-change behaviour: when git cannot answer, every path here collapses to
102
+ * `<startDir-root>/.webpieces` exactly as before. Degrading to merely-suboptimal beats throwing on a
103
+ * hook's blocking path.
104
+ */
105
+ let DotWebpieces = class DotWebpieces {
106
+ migrator;
107
+ // treeRoot → git's answer. One `git rev-parse` pair per root per process; every later path lookup
108
+ // in that invocation is a Map hit.
109
+ gitDirsByRoot = new Map();
110
+ // Roots whose legacy per-worktree `.webpieces/` has already been considered for migration.
111
+ migrated = new Set();
112
+ constructor(migrator = new state_dir_migration_1.StateDirMigrator()) {
113
+ this.migrator = migrator;
114
+ }
115
+ /**
116
+ * REPO-WIDE state: `<primary>/.webpieces`. Use ONLY for facts about the repo rather than about one
117
+ * worktree — today that is merged-branches.json and the main-sync status + lock. Identical from
118
+ * every worktree, and never behind an indirection, so an atomic `rename()` into it is safe.
119
+ */
120
+ shared(startDir) {
121
+ return path.join(this.primaryRoot(startDir), constants_1.WEBPIECES_TMP_DIR);
122
+ }
123
+ /** A path beneath the repo-wide state dir. */
124
+ sharedFile(startDir, ...segments) {
125
+ return path.join(this.shared(startDir), ...segments);
126
+ }
127
+ /**
128
+ * THIS WORKTREE's private state: `<primary>/.webpieces/worktrees/<name>` for a linked worktree, and
129
+ * `<primary>/.webpieces` for the primary clone, which keeps its state exactly where it has always
130
+ * been. Fully isolated — two worktrees never write the same path, so nothing here needs a lock.
131
+ *
132
+ * The first call for a linked worktree also MIGRATES a legacy real `<worktree>/.webpieces/`
133
+ * directory into the namespace, so in-flight merge / pr-review state written under the old scheme
134
+ * (or by an older PUBLISHED build during the transition) is picked up rather than orphaned.
135
+ */
136
+ local(startDir) {
137
+ const dirs = this.gitDirs(startDir);
138
+ if (dirs === null || !dirs.isLinkedWorktree)
139
+ return this.shared(startDir);
140
+ const target = path.join(this.shared(startDir), exports.WORKTREE_STATE_DIR, path.basename(dirs.gitDir));
141
+ this.migrateOnce(startDir, target);
142
+ return target;
143
+ }
144
+ /** A path beneath this worktree's private state dir. */
145
+ localFile(startDir, ...segments) {
146
+ return path.join(this.local(startDir), ...segments);
147
+ }
148
+ /** True when `startDir` sits in a LINKED worktree rather than the primary clone. */
149
+ isLinkedWorktree(startDir) {
150
+ const dirs = this.gitDirs(startDir);
151
+ return dirs !== null && dirs.isLinkedWorktree;
152
+ }
153
+ /**
154
+ * git's own name for this linked worktree — the basename of `<primary>/.git/worktrees/<name>`, and
155
+ * the namespace key under `worktrees/`. Empty for the primary clone. git's name rather than the
156
+ * directory's basename, so two worktrees checked out into same-named directories under different
157
+ * parents cannot collide.
158
+ */
159
+ worktreeName(startDir) {
160
+ const dirs = this.gitDirs(startDir);
161
+ if (dirs === null || !dirs.isLinkedWorktree)
162
+ return '';
163
+ return path.basename(dirs.gitDir);
164
+ }
165
+ /** The primary clone's root, from any worktree. Falls back to git's toplevel-less best guess. */
166
+ primaryRoot(startDir) {
167
+ const dirs = this.gitDirs(startDir);
168
+ if (dirs === null)
169
+ return startDir;
170
+ if (path.basename(dirs.commonDir) !== GIT_DIR_NAME)
171
+ return startDir;
172
+ const primary = path.dirname(dirs.commonDir);
173
+ return fs.existsSync(primary) ? primary : startDir;
174
+ }
175
+ /**
176
+ * The PRE-change location, `<treeRoot>/.webpieces` — what every call site used to compute. Public
177
+ * because the migrator and its specs must be able to name the thing being migrated FROM, and
178
+ * because the transition-window fallback readers need it.
179
+ */
180
+ legacyDir(treeRoot) {
181
+ return path.join(treeRoot, constants_1.WEBPIECES_TMP_DIR);
182
+ }
183
+ // Drain a legacy per-worktree `.webpieces/` into this worktree's namespace, at most once per tree
184
+ // per process. Migration is idempotent, but it touches the filesystem on the hook's blocking path.
185
+ migrateOnce(startDir, target) {
186
+ // Guard on the CHEAP key first. `local()` is called many times per invocation, and resolving the
187
+ // worktree toplevel costs a `git rev-parse` — doing that before the once-check would put a
188
+ // process spawn on the hook's blocking path for every single state-path lookup.
189
+ if (this.migrated.has(startDir))
190
+ return;
191
+ this.migrated.add(startDir);
192
+ const toplevel = this.gitToplevel(startDir);
193
+ if (toplevel === null)
194
+ return;
195
+ this.migrator.migrate(this.legacyDir(toplevel), target);
196
+ }
197
+ // Both git dirs for `startDir`, cached, or null when this is not a git repo / git is unavailable.
198
+ // `status !== 0` IS the expected "not a repo" answer (spawnSync does not throw on a non-zero exit),
199
+ // so there is no try/catch here swallowing a real git crash. Mirrors RepoRootFinder.gitToplevel.
200
+ gitDirs(startDir) {
201
+ const cached = this.gitDirsByRoot.get(startDir);
202
+ if (cached !== undefined)
203
+ return cached;
204
+ const gitDir = this.revParse(startDir, '--git-dir');
205
+ const commonDir = this.revParse(startDir, '--git-common-dir');
206
+ const dirs = gitDir === null || commonDir === null ? null : new GitDirs(gitDir, commonDir);
207
+ this.gitDirsByRoot.set(startDir, dirs);
208
+ return dirs;
209
+ }
210
+ gitToplevel(startDir) {
211
+ return this.revParse(startDir, '--show-toplevel');
212
+ }
213
+ // One `git rev-parse <flag>`, resolved to an absolute path (git prints a bare `.git`, relative to
214
+ // the tree, in the primary clone, and an absolute path from a linked worktree).
215
+ revParse(cwd, flag) {
216
+ const result = (0, child_process_1.spawnSync)('git', ['-C', cwd, 'rev-parse', flag], { encoding: 'utf8' });
217
+ if (result.status !== 0)
218
+ return null;
219
+ const printed = (result.stdout ?? '').trim();
220
+ if (printed === '')
221
+ return null;
222
+ return path.resolve(cwd, printed);
223
+ }
224
+ };
225
+ exports.DotWebpieces = DotWebpieces;
226
+ exports.DotWebpieces = DotWebpieces = tslib_1.__decorate([
227
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
228
+ tslib_1.__metadata("design:paramtypes", [state_dir_migration_1.StateDirMigrator])
229
+ ], DotWebpieces);
230
+ // Process-wide instance for the many non-DI call sites (hooks, detached refreshers, wp-* bins, eslint
231
+ // rules). Sharing one instance is what makes the git-resolution cache and the once-per-tree migration
232
+ // worth having; inversify still injects the singleton wherever a container is in play.
233
+ exports.dotWebpieces = new DotWebpieces();
234
+ //# sourceMappingURL=state-dir.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-dir.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/state-dir.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { WEBPIECES_TMP_DIR } from './constants';\nimport { StateDirMigrator } from './state-dir-migration';\n\n// The per-worktree namespace inside the primary clone's `.webpieces/`. A LINKED worktree's local state\n// lives at `<primary>/.webpieces/worktrees/<worktreeName>/`; the primary clone keeps using\n// `<primary>/.webpieces/` directly, exactly as it always has.\nexport const WORKTREE_STATE_DIR = 'worktrees';\n\n// git prints the shared git dir as `<primary>/.git` for a conventional clone. Anything else (a bare\n// repo, `--separate-git-dir`) is a layout we decline to derive a working tree from.\nconst GIT_DIR_NAME = '.git';\n\n/**\n * Data-only carrier for the two paths git is asked for. Per CLAUDE.md: classes for data.\n *\n * `gitDir` is the PER-WORKTREE git dir (`<primary>/.git/worktrees/<name>` in a linked worktree,\n * `<primary>/.git` in the primary clone). `commonDir` is the SHARED one — always `<primary>/.git`.\n * They differ if and only if this is a linked worktree; that is git's own canonical test.\n */\nexport class GitDirs {\n readonly gitDir: string;\n readonly commonDir: string;\n\n constructor(gitDir: string, commonDir: string) {\n this.gitDir = gitDir;\n this.commonDir = commonDir;\n }\n\n get isLinkedWorktree(): boolean {\n return path.resolve(this.gitDir) !== path.resolve(this.commonDir);\n }\n}\n\n/**\n * WHERE a piece of `.webpieces/` state belongs — the ONE resolver every reader and writer must go\n * through, with TWO named methods because there are exactly two answers and a call site must DECLARE\n * which one it means.\n *\n * dotWebpieces.shared(dir) → <primary>/.webpieces (repo-wide facts)\n * dotWebpieces.local(dir) → <primary>/.webpieces/worktrees/<name> (this worktree only)\n * → <primary>/.webpieces (…in the primary clone)\n *\n * ─── The bug ───────────────────────────────────────────────────────────────────────────────────────\n * `.webpieces/` is gitignored, and it was anchored at the directory holding webpieces.config.json —\n * which in a linked worktree is the WORKTREE. So a repo with seven worktrees had SEVEN independent\n * copies of files that describe the WHOLE REPO. `merged-branches.json` holds verdicts for every branch\n * AND every worktree in the repo; N copies is N divergent truths, and the guards read them as fact.\n * Observed in the field: branch-creation-guard asserted \"8 parked local branches\" while\n * `git branch --list` showed ONE — it was reading a cache written before deletions performed from a\n * DIFFERENT worktree — and it then blocked a legitimate `git worktree add` on that fiction.\n *\n * ─── Why two explicit methods and not a symlink ────────────────────────────────────────────────────\n * A `<worktree>/.webpieces` → `<primary>/…/worktrees/<name>` symlink would have left every call site\n * untouched, which is seductive and wrong. Nothing hooks `git worktree add`, so the link needs LAZY\n * creation that also has to handle \"link already exists\", \"link points somewhere else\", and \"a real\n * directory is already there\" — invisible filesystem magic with a Windows failure mode. Worse, the safe\n * way to write a shared file under concurrency is temp-file-then-`rename()`, and `rename(2)` acts on\n * the PATH, not the link: it REPLACES a symlink with a real file. That silently works for one writer\n * and diverges for everyone else — the exact bug being fixed, but invisible. An explicit call is\n * greppable, testable, and forces each site to say which scope it means.\n *\n * ─── Which scope is which (the scope assignment is deliberate, not incidental) ─────────────────────\n * shared():\n * • merged-branches.json — verdicts for every branch and worktree in the repo. Atomically written.\n * • main-sync-status.json AND main-sync.lock.json — there is one `main` and one `.git`, so there is\n * one refresher. A lock inside a per-worktree directory locks nothing.\n * local():\n * • hooks/*.log, INCLUDING branch-mutations.log. A shared append-only log genuinely corrupts:\n * `O_APPEND` writes are indivisible only under PIPE_BUF, which is 512 bytes on macOS, and a\n * `recover=git worktree add -b <branch> <abs-path> <tag>` line with real paths exceeds that. A\n * per-worktree log has exactly ONE writer and cannot tear, and under this layout it already\n * survives the worktree's deletion — recovery is one glob over\n * `<primary>/.webpieces/worktrees/*/hooks/branch-mutations.log`.\n * • merge-info/staged|merged/<branch>/ and its index.json, pr-review/<branch>/, instruct-ai/, and\n * every other per-tree scratch file.\n *\n * ─── The boundary that must not be blurred ─────────────────────────────────────────────────────────\n * ONLY the gitignored `.webpieces/` STATE relocates. `webpieces.config.json` is TRACKED IN GIT and is\n * therefore part of the BRANCH: a branch may legitimately change its own rules and that must keep\n * working. Config resolution is untouched — still per-worktree, via findConfigFile /\n * RepoRootFinder.resolveRepoRoot. Nothing in this class reads or moves config.\n *\n * ─── Why `--git-dir` / `--git-common-dir`, and not one of the existing services ────────────────────\n * They are git's own answers, from any subdirectory, in one cheap local call, with no `.git`-file\n * parsing by hand (which gets `--separate-git-dir` and submodules wrong). Two existing mechanisms do\n * distinguish primary from linked, and neither is the right authority here:\n * • `WorktreeService` answers \"what worktrees exist and what do they hold\" — a repo-wide ENUMERATION\n * (`git worktree list --porcelain`) for the caps and the reaper. Using it for a path lookup would\n * run an enumeration on the hook's blocking path for every state access, and it fails SOFT to `[]`,\n * which here would read as \"there is no primary clone\" on exactly the degraded repo where the\n * answer matters most. It also does not expose the worktree's git NAME, which is the namespace key.\n * • `EffectiveTreeResolver` (#524) answers \"which tree does this COMMAND act on\" — it takes a command\n * string and is a policy input to the bash guards, not a filesystem-path resolver.\n * Both remain authoritative for their own questions. This asks the narrowest one — two path strings —\n * and lives in rules-config, UNDER both, which is where a primitive that pr-gate, ai-hook-rules and\n * code-rules all need has to sit.\n *\n * Fails CLOSED to the pre-change behaviour: when git cannot answer, every path here collapses to\n * `<startDir-root>/.webpieces` exactly as before. Degrading to merely-suboptimal beats throwing on a\n * hook's blocking path.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class DotWebpieces {\n // treeRoot → git's answer. One `git rev-parse` pair per root per process; every later path lookup\n // in that invocation is a Map hit.\n private readonly gitDirsByRoot = new Map<string, GitDirs | null>();\n // Roots whose legacy per-worktree `.webpieces/` has already been considered for migration.\n private readonly migrated = new Set<string>();\n\n constructor(private readonly migrator: StateDirMigrator = new StateDirMigrator()) {}\n\n /**\n * REPO-WIDE state: `<primary>/.webpieces`. Use ONLY for facts about the repo rather than about one\n * worktree — today that is merged-branches.json and the main-sync status + lock. Identical from\n * every worktree, and never behind an indirection, so an atomic `rename()` into it is safe.\n */\n shared(startDir: string): string {\n return path.join(this.primaryRoot(startDir), WEBPIECES_TMP_DIR);\n }\n\n /** A path beneath the repo-wide state dir. */\n sharedFile(startDir: string, ...segments: string[]): string {\n return path.join(this.shared(startDir), ...segments);\n }\n\n /**\n * THIS WORKTREE's private state: `<primary>/.webpieces/worktrees/<name>` for a linked worktree, and\n * `<primary>/.webpieces` for the primary clone, which keeps its state exactly where it has always\n * been. Fully isolated — two worktrees never write the same path, so nothing here needs a lock.\n *\n * The first call for a linked worktree also MIGRATES a legacy real `<worktree>/.webpieces/`\n * directory into the namespace, so in-flight merge / pr-review state written under the old scheme\n * (or by an older PUBLISHED build during the transition) is picked up rather than orphaned.\n */\n local(startDir: string): string {\n const dirs = this.gitDirs(startDir);\n if (dirs === null || !dirs.isLinkedWorktree) return this.shared(startDir);\n\n const target = path.join(this.shared(startDir), WORKTREE_STATE_DIR, path.basename(dirs.gitDir));\n this.migrateOnce(startDir, target);\n return target;\n }\n\n /** A path beneath this worktree's private state dir. */\n localFile(startDir: string, ...segments: string[]): string {\n return path.join(this.local(startDir), ...segments);\n }\n\n /** True when `startDir` sits in a LINKED worktree rather than the primary clone. */\n isLinkedWorktree(startDir: string): boolean {\n const dirs = this.gitDirs(startDir);\n return dirs !== null && dirs.isLinkedWorktree;\n }\n\n /**\n * git's own name for this linked worktree — the basename of `<primary>/.git/worktrees/<name>`, and\n * the namespace key under `worktrees/`. Empty for the primary clone. git's name rather than the\n * directory's basename, so two worktrees checked out into same-named directories under different\n * parents cannot collide.\n */\n worktreeName(startDir: string): string {\n const dirs = this.gitDirs(startDir);\n if (dirs === null || !dirs.isLinkedWorktree) return '';\n return path.basename(dirs.gitDir);\n }\n\n /** The primary clone's root, from any worktree. Falls back to git's toplevel-less best guess. */\n primaryRoot(startDir: string): string {\n const dirs = this.gitDirs(startDir);\n if (dirs === null) return startDir;\n if (path.basename(dirs.commonDir) !== GIT_DIR_NAME) return startDir;\n const primary = path.dirname(dirs.commonDir);\n return fs.existsSync(primary) ? primary : startDir;\n }\n\n /**\n * The PRE-change location, `<treeRoot>/.webpieces` — what every call site used to compute. Public\n * because the migrator and its specs must be able to name the thing being migrated FROM, and\n * because the transition-window fallback readers need it.\n */\n legacyDir(treeRoot: string): string {\n return path.join(treeRoot, WEBPIECES_TMP_DIR);\n }\n\n // Drain a legacy per-worktree `.webpieces/` into this worktree's namespace, at most once per tree\n // per process. Migration is idempotent, but it touches the filesystem on the hook's blocking path.\n private migrateOnce(startDir: string, target: string): void {\n // Guard on the CHEAP key first. `local()` is called many times per invocation, and resolving the\n // worktree toplevel costs a `git rev-parse` — doing that before the once-check would put a\n // process spawn on the hook's blocking path for every single state-path lookup.\n if (this.migrated.has(startDir)) return;\n this.migrated.add(startDir);\n const toplevel = this.gitToplevel(startDir);\n if (toplevel === null) return;\n this.migrator.migrate(this.legacyDir(toplevel), target);\n }\n\n // Both git dirs for `startDir`, cached, or null when this is not a git repo / git is unavailable.\n // `status !== 0` IS the expected \"not a repo\" answer (spawnSync does not throw on a non-zero exit),\n // so there is no try/catch here swallowing a real git crash. Mirrors RepoRootFinder.gitToplevel.\n private gitDirs(startDir: string): GitDirs | null {\n const cached = this.gitDirsByRoot.get(startDir);\n if (cached !== undefined) return cached;\n\n const gitDir = this.revParse(startDir, '--git-dir');\n const commonDir = this.revParse(startDir, '--git-common-dir');\n const dirs = gitDir === null || commonDir === null ? null : new GitDirs(gitDir, commonDir);\n this.gitDirsByRoot.set(startDir, dirs);\n return dirs;\n }\n\n private gitToplevel(startDir: string): string | null {\n return this.revParse(startDir, '--show-toplevel');\n }\n\n // One `git rev-parse <flag>`, resolved to an absolute path (git prints a bare `.git`, relative to\n // the tree, in the primary clone, and an absolute path from a linked worktree).\n private revParse(cwd: string, flag: string): string | null {\n const result = spawnSync('git', ['-C', cwd, 'rev-parse', flag], { encoding: 'utf8' });\n if (result.status !== 0) return null;\n const printed = (result.stdout ?? '').trim();\n if (printed === '') return null;\n return path.resolve(cwd, printed);\n }\n}\n\n// Process-wide instance for the many non-DI call sites (hooks, detached refreshers, wp-* bins, eslint\n// rules). Sharing one instance is what makes the git-resolution cache and the once-per-tree migration\n// worth having; inversify still injects the singleton wherever a container is in play.\nexport const dotWebpieces = new DotWebpieces();\n"]}
@@ -51,10 +51,54 @@ squash commit. If a command tells you the tree is dirty, commit or delete the li
51
51
  - **B = feature HEAD**: your branch tip
52
52
  - **C = main HEAD**: `origin/main`
53
53
 
54
- Never `git merge` or `git rebase` main into your branch directly — it breaks the fork point (A) that
55
- produces clean PR diffs. Sync only via the gated 3-point squash-update — `pnpm wp-start-update` when no
56
- PR is open, `pnpm wp-start-upsert-pr` when one is (see "Which flow?" below). This is enforced by the
57
- `redirect-how-to-merge-main` guard.
54
+ ### THE FORK POINT INVARIANT (read this once, it explains every rule below)
55
+
56
+ > The fork point must be a **pure `main` commit containing none of your work**, AND your branch must
57
+ > contain **none of main's commits after it**. Only then is `diff(fork point → HEAD)` *exactly* your
58
+ > changes — no more and no less.
59
+
60
+ The invariant is symmetric, and both halves matter. Pollute the fork point with your own work and the
61
+ diff *understates* what you did; pull main's commits onto your branch and the diff *overstates* it with
62
+ work other people already landed. Either way the answer to "what did this branch change?" stops being
63
+ computable, and three separate consumers are asking exactly that question:
64
+
65
+ 1. **The 3-point merge.** `A` is the base, `B` is what *you* intended, `C` is what *main* intended, and
66
+ the whole method is reading `B−A` against `C−A` to see the two intentions side by side. If `A`
67
+ already contains your work, `B−A` no longer shows what you changed; if `B` already contains main's
68
+ commits, `C−A` is being compared against a branch that partly *is* main. The diffs stop describing
69
+ intentions and conflict resolution degrades into guessing which side a hunk came from.
70
+ 2. **`nx affected` — the build gate's scope.** The gate's `--base` is the fork point, not
71
+ `origin/main`, and the config states why verbatim:
72
+ > `--base is the FORK POINT via $(git merge-base origin/main HEAD), NOT origin/main: basing on origin/main marks projects from other people's already-merged PRs as 'affected' (your branch still has their pre-merge versions), wasting rebuilds. The fork point scopes affected to only your branch's work.`
73
+ 3. **The review diff.** `wp-review-upsert-pr` extracts this branch's diff for the reviewer subagents
74
+ from the same two endpoints. A polluted fork point hands reviewers changes nobody on this branch
75
+ made, and they will dutifully review them.
76
+
77
+ **Why an ordinary `git merge main` breaks it:** it violates *both* halves in one command. Your branch
78
+ now carries main's commits (so the diff shows work you did not do) and the base is no longer a clean
79
+ main state. That is why the `redirect-how-to-merge-main` guard blocks `git merge` and `git rebase` in
80
+ every form — the block is not stylistic, and there is nothing to work around.
81
+
82
+ **Why the gated flow squash-rewrites and force-pushes:** it restores the invariant *by construction*.
83
+ After a sync your branch is `current main + one commit that is precisely your work`, so the fork point
84
+ is a real, unmodified main commit by definition. The rewrite **is** the mechanism, not a side effect of
85
+ one — which is why the flow cannot politely skip it.
86
+
87
+ **Why squash rather than rebase:** a rebase would restore the same invariant, but it preserves N
88
+ commits and makes you resolve the same conflict once per commit. Squash gives you one resolution and
89
+ one commit. And since this repo squash-merges PRs anyway, those individual commits were never going to
90
+ survive into main's history — the `<feature>PreMerge<n>` snapshot branches exist precisely to preserve
91
+ the original pre-squash history for debugging, which is their entire purpose.
92
+
93
+ **Consequence — never sync a branch you do not own.** Because the flow rewrites history and
94
+ force-pushes, **never run `pnpm wp-start-upsert-pr` (or `pnpm wp-start-update`) against a branch that
95
+ another process, session, or agent owns.** This is not caution or etiquette, it is a correctness
96
+ requirement: that other process's fork point and its `PreMerge<n>` snapshot trail would be replaced
97
+ underneath it mid-flight, and the work it was holding becomes unrecoverable-by-construction. If you
98
+ find yourself looking at an open PR on a branch this session did not create, leave it alone.
99
+
100
+ Sync only via the gated 3-point squash-update — `pnpm wp-start-update` when no PR is open,
101
+ `pnpm wp-start-upsert-pr` when one is (see "Which flow?" below).
58
102
 
59
103
  **AI never runs `git merge` or `git rebase` — at all.** The guard blocks both outright: on any branch,
60
104
  against any target, in any form (including `--squash` and `--ff-only`), and in any compound command