@webpieces/rules-config 0.4.420 → 0.4.421
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.
- package/package.json +1 -1
- package/src/worktrees.d.ts +24 -0
- package/src/worktrees.js +44 -0
- package/src/worktrees.js.map +1 -1
- package/templates/webpieces.git-workflow.md +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/rules-config",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.421",
|
|
4
4
|
"description": "Shared webpieces.config.json loader. Single source of truth for validation rule configuration consumed by @webpieces/ai-hook-rules, @webpieces/code-rules, and @webpieces/nx-webpieces-rules.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/worktrees.d.ts
CHANGED
|
@@ -22,6 +22,30 @@ export declare class WorktreeService {
|
|
|
22
22
|
* whole reap command down with it), and it must not be counted as a parked branch either.
|
|
23
23
|
*/
|
|
24
24
|
heldBranches(repoRoot: string): Set<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Am I standing in a LINKED worktree (as opposed to the primary clone)?
|
|
27
|
+
*
|
|
28
|
+
* This is the question every recovery message needs, because the two trees take different
|
|
29
|
+
* commands: `git checkout main` fatals inside a linked worktree ("main is already checked out
|
|
30
|
+
* at <primary>"), and a dead linked worktree is reaped with `git worktree remove`, not
|
|
31
|
+
* `git branch -d`. A guard that cannot tell them apart must print BOTH forms and let the AI
|
|
32
|
+
* guess — which is exactly how an AI ends up running the fatal one.
|
|
33
|
+
*
|
|
34
|
+
* The test is a single `statSync`, no process spawn: git gives a linked worktree a `.git` FILE
|
|
35
|
+
* (a `gitdir:` pointer) where the primary clone has a `.git` DIRECTORY. This runs on the read
|
|
36
|
+
* path, where reads vastly outnumber every other tool call, so the cost matters.
|
|
37
|
+
*
|
|
38
|
+
* Returns FALSE on anything uncertain (no `.git` at all, unreadable, a submodule's `.git` file
|
|
39
|
+
* in a non-worktree checkout). False is the fail-open direction here: callers then print both
|
|
40
|
+
* forms rather than confidently printing the wrong one.
|
|
41
|
+
*/
|
|
42
|
+
isLinkedWorktree(root: string): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The worktree record for the tree rooted at `root`, or null when it is not one of git's
|
|
45
|
+
* worktrees (or git could not answer). Callers use it to name the exact directory a
|
|
46
|
+
* `git worktree remove` has to take — a reap instruction with the wrong path is worse than none.
|
|
47
|
+
*/
|
|
48
|
+
currentWorktree(root: string): Worktree | null;
|
|
25
49
|
/**
|
|
26
50
|
* Parse the porcelain records. A record starts at a `worktree <path>` line and runs to the blank
|
|
27
51
|
* line; the FIRST record is the main worktree (git guarantees the ordering). A `prunable` worktree
|
package/src/worktrees.js
CHANGED
|
@@ -3,7 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.WorktreeService = exports.Worktree = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
7
|
+
const path = tslib_1.__importStar(require("path"));
|
|
6
8
|
const inversify_1 = require("inversify");
|
|
9
|
+
const to_error_1 = require("./to-error");
|
|
7
10
|
/**
|
|
8
11
|
* Reading the git worktree list.
|
|
9
12
|
*
|
|
@@ -72,6 +75,47 @@ let WorktreeService = class WorktreeService {
|
|
|
72
75
|
}
|
|
73
76
|
return held;
|
|
74
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Am I standing in a LINKED worktree (as opposed to the primary clone)?
|
|
80
|
+
*
|
|
81
|
+
* This is the question every recovery message needs, because the two trees take different
|
|
82
|
+
* commands: `git checkout main` fatals inside a linked worktree ("main is already checked out
|
|
83
|
+
* at <primary>"), and a dead linked worktree is reaped with `git worktree remove`, not
|
|
84
|
+
* `git branch -d`. A guard that cannot tell them apart must print BOTH forms and let the AI
|
|
85
|
+
* guess — which is exactly how an AI ends up running the fatal one.
|
|
86
|
+
*
|
|
87
|
+
* The test is a single `statSync`, no process spawn: git gives a linked worktree a `.git` FILE
|
|
88
|
+
* (a `gitdir:` pointer) where the primary clone has a `.git` DIRECTORY. This runs on the read
|
|
89
|
+
* path, where reads vastly outnumber every other tool call, so the cost matters.
|
|
90
|
+
*
|
|
91
|
+
* Returns FALSE on anything uncertain (no `.git` at all, unreadable, a submodule's `.git` file
|
|
92
|
+
* in a non-worktree checkout). False is the fail-open direction here: callers then print both
|
|
93
|
+
* forms rather than confidently printing the wrong one.
|
|
94
|
+
*/
|
|
95
|
+
isLinkedWorktree(root) {
|
|
96
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
97
|
+
try {
|
|
98
|
+
return !fs.statSync(path.join(root, '.git')).isDirectory();
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
const error = (0, to_error_1.toError)(err);
|
|
102
|
+
void error;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The worktree record for the tree rooted at `root`, or null when it is not one of git's
|
|
108
|
+
* worktrees (or git could not answer). Callers use it to name the exact directory a
|
|
109
|
+
* `git worktree remove` has to take — a reap instruction with the wrong path is worse than none.
|
|
110
|
+
*/
|
|
111
|
+
currentWorktree(root) {
|
|
112
|
+
const resolved = path.resolve(root);
|
|
113
|
+
for (const tree of this.listWorktrees(root)) {
|
|
114
|
+
if (path.resolve(tree.path) === resolved)
|
|
115
|
+
return tree;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
75
119
|
/**
|
|
76
120
|
* Parse the porcelain records. A record starts at a `worktree <path>` line and runs to the blank
|
|
77
121
|
* line; the FIRST record is the main worktree (git guarantees the ordering). A `prunable` worktree
|
package/src/worktrees.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worktrees.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/worktrees.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n/**\n * Reading the git worktree list.\n *\n * WHY this exists separately from merged-branches: a worktree is a SECOND budget. Every worktree holds\n * a branch, so if worktree-held branches also counted against the local-branch cap, five worktrees\n * would consume the entire branch budget and no branch could ever be created again. The two are\n * therefore counted apart — held branches against the worktree cap, parked branches against the branch\n * cap — and this service is what tells the two apart.\n *\n * Unlike the merged-PR lookup, everything here is LOCAL and cheap (one `git worktree list`), so it is\n * safe to call on the guard's blocking path.\n */\n\n// `git worktree list --porcelain` emits blank-line-separated records. The first record is always the\n// main worktree. Keys are space-separated; `detached`, `bare` and `locked` may appear bare (no value).\nconst WORKTREE_KEY = 'worktree ';\nconst BRANCH_KEY = 'branch ';\nconst REFS_HEADS = 'refs/heads/';\n\n// Data-only (per CLAUDE.md, classes for data).\nexport class Worktree {\n path: string;\n // Short branch name (refs/heads/ stripped). Empty when the worktree is detached or bare.\n branch: string;\n // The primary clone — the one that owns .git. Never counted against the cap, never removable.\n isMain: boolean;\n // git already knows this worktree's directory is gone; `git worktree prune` will clear it.\n prunable: boolean;\n // A human ran `git worktree lock`. Explicitly \"do not touch\".\n locked: boolean;\n\n constructor(path: string, branch: string, isMain: boolean, prunable: boolean, locked: boolean) {\n this.path = path;\n this.branch = branch;\n this.isMain = isMain;\n this.prunable = prunable;\n this.locked = locked;\n }\n}\n\n// Result of a captured git invocation: ok=false on spawn failure or non-zero exit.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class WorktreeService {\n /**\n * Every worktree, main one first. Fails SOFT to [] — a repo with no worktree support, or a git\n * that errors, must read as \"no worktrees\" so the cap fails OPEN rather than blocking on data we\n * do not have.\n */\n listWorktrees(repoRoot: string): Worktree[] {\n const result = this.capture(repoRoot, ['worktree', 'list', '--porcelain']);\n if (!result.ok || result.out === '') return [];\n return this.parsePorcelain(result.out);\n }\n\n // The linked worktrees — everything except the primary clone. This is what the cap counts.\n linkedWorktrees(repoRoot: string): Worktree[] {\n return this.listWorktrees(repoRoot).filter((tree: Worktree): boolean => !tree.isMain);\n }\n\n /**\n * Branch names checked out in ANY worktree, including the primary clone's own HEAD.\n *\n * Two callers, one reason: git flatly refuses to delete a branch that is checked out somewhere.\n * A held branch must never be proposed for `git branch -D` (the delete would fail and take the\n * whole reap command down with it), and it must not be counted as a parked branch either.\n */\n heldBranches(repoRoot: string): Set<string> {\n const held = new Set<string>();\n for (const tree of this.listWorktrees(repoRoot)) {\n if (tree.branch !== '') held.add(tree.branch);\n }\n return held;\n }\n\n /**\n * Parse the porcelain records. A record starts at a `worktree <path>` line and runs to the blank\n * line; the FIRST record is the main worktree (git guarantees the ordering). A `prunable` worktree\n * still appears in the list, which is exactly why it can be reaped.\n */\n private parsePorcelain(out: string): Worktree[] {\n const trees: Worktree[] = [];\n let path = '';\n let branch = '';\n let prunable = false;\n let locked = false;\n\n const flush = (): void => {\n if (path === '') return;\n trees.push(new Worktree(path, branch, trees.length === 0, prunable, locked));\n path = '';\n branch = '';\n prunable = false;\n locked = false;\n };\n\n for (const raw of out.split('\\n')) {\n const line = raw.trim();\n if (line === '') {\n flush();\n } else if (line.startsWith(WORKTREE_KEY)) {\n // A new record begins — flush the previous one in case the blank line was missing.\n flush();\n path = line.slice(WORKTREE_KEY.length).trim();\n } else if (line.startsWith(BRANCH_KEY)) {\n const ref = line.slice(BRANCH_KEY.length).trim();\n branch = ref.startsWith(REFS_HEADS) ? ref.slice(REFS_HEADS.length) : ref;\n } else if (line === 'prunable' || line.startsWith('prunable ')) {\n prunable = true;\n } else if (line === 'locked' || line.startsWith('locked ')) {\n locked = true;\n }\n }\n flush();\n\n return trees;\n }\n\n // Run a git command capturing trimmed stdout; ok=false on spawn failure or non-zero exit.\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n if (result.status !== 0 || typeof result.stdout !== 'string') return { ok: false, out: '' };\n return { ok: true, out: result.stdout.trim() };\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"worktrees.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/worktrees.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 { toError } from './to-error';\n\n/**\n * Reading the git worktree list.\n *\n * WHY this exists separately from merged-branches: a worktree is a SECOND budget. Every worktree holds\n * a branch, so if worktree-held branches also counted against the local-branch cap, five worktrees\n * would consume the entire branch budget and no branch could ever be created again. The two are\n * therefore counted apart — held branches against the worktree cap, parked branches against the branch\n * cap — and this service is what tells the two apart.\n *\n * Unlike the merged-PR lookup, everything here is LOCAL and cheap (one `git worktree list`), so it is\n * safe to call on the guard's blocking path.\n */\n\n// `git worktree list --porcelain` emits blank-line-separated records. The first record is always the\n// main worktree. Keys are space-separated; `detached`, `bare` and `locked` may appear bare (no value).\nconst WORKTREE_KEY = 'worktree ';\nconst BRANCH_KEY = 'branch ';\nconst REFS_HEADS = 'refs/heads/';\n\n// Data-only (per CLAUDE.md, classes for data).\nexport class Worktree {\n path: string;\n // Short branch name (refs/heads/ stripped). Empty when the worktree is detached or bare.\n branch: string;\n // The primary clone — the one that owns .git. Never counted against the cap, never removable.\n isMain: boolean;\n // git already knows this worktree's directory is gone; `git worktree prune` will clear it.\n prunable: boolean;\n // A human ran `git worktree lock`. Explicitly \"do not touch\".\n locked: boolean;\n\n constructor(path: string, branch: string, isMain: boolean, prunable: boolean, locked: boolean) {\n this.path = path;\n this.branch = branch;\n this.isMain = isMain;\n this.prunable = prunable;\n this.locked = locked;\n }\n}\n\n// Result of a captured git invocation: ok=false on spawn failure or non-zero exit.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class WorktreeService {\n /**\n * Every worktree, main one first. Fails SOFT to [] — a repo with no worktree support, or a git\n * that errors, must read as \"no worktrees\" so the cap fails OPEN rather than blocking on data we\n * do not have.\n */\n listWorktrees(repoRoot: string): Worktree[] {\n const result = this.capture(repoRoot, ['worktree', 'list', '--porcelain']);\n if (!result.ok || result.out === '') return [];\n return this.parsePorcelain(result.out);\n }\n\n // The linked worktrees — everything except the primary clone. This is what the cap counts.\n linkedWorktrees(repoRoot: string): Worktree[] {\n return this.listWorktrees(repoRoot).filter((tree: Worktree): boolean => !tree.isMain);\n }\n\n /**\n * Branch names checked out in ANY worktree, including the primary clone's own HEAD.\n *\n * Two callers, one reason: git flatly refuses to delete a branch that is checked out somewhere.\n * A held branch must never be proposed for `git branch -D` (the delete would fail and take the\n * whole reap command down with it), and it must not be counted as a parked branch either.\n */\n heldBranches(repoRoot: string): Set<string> {\n const held = new Set<string>();\n for (const tree of this.listWorktrees(repoRoot)) {\n if (tree.branch !== '') held.add(tree.branch);\n }\n return held;\n }\n\n /**\n * Am I standing in a LINKED worktree (as opposed to the primary clone)?\n *\n * This is the question every recovery message needs, because the two trees take different\n * commands: `git checkout main` fatals inside a linked worktree (\"main is already checked out\n * at <primary>\"), and a dead linked worktree is reaped with `git worktree remove`, not\n * `git branch -d`. A guard that cannot tell them apart must print BOTH forms and let the AI\n * guess — which is exactly how an AI ends up running the fatal one.\n *\n * The test is a single `statSync`, no process spawn: git gives a linked worktree a `.git` FILE\n * (a `gitdir:` pointer) where the primary clone has a `.git` DIRECTORY. This runs on the read\n * path, where reads vastly outnumber every other tool call, so the cost matters.\n *\n * Returns FALSE on anything uncertain (no `.git` at all, unreadable, a submodule's `.git` file\n * in a non-worktree checkout). False is the fail-open direction here: callers then print both\n * forms rather than confidently printing the wrong one.\n */\n isLinkedWorktree(root: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return !fs.statSync(path.join(root, '.git')).isDirectory();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n /**\n * The worktree record for the tree rooted at `root`, or null when it is not one of git's\n * worktrees (or git could not answer). Callers use it to name the exact directory a\n * `git worktree remove` has to take — a reap instruction with the wrong path is worse than none.\n */\n currentWorktree(root: string): Worktree | null {\n const resolved = path.resolve(root);\n for (const tree of this.listWorktrees(root)) {\n if (path.resolve(tree.path) === resolved) return tree;\n }\n return null;\n }\n\n /**\n * Parse the porcelain records. A record starts at a `worktree <path>` line and runs to the blank\n * line; the FIRST record is the main worktree (git guarantees the ordering). A `prunable` worktree\n * still appears in the list, which is exactly why it can be reaped.\n */\n private parsePorcelain(out: string): Worktree[] {\n const trees: Worktree[] = [];\n let path = '';\n let branch = '';\n let prunable = false;\n let locked = false;\n\n const flush = (): void => {\n if (path === '') return;\n trees.push(new Worktree(path, branch, trees.length === 0, prunable, locked));\n path = '';\n branch = '';\n prunable = false;\n locked = false;\n };\n\n for (const raw of out.split('\\n')) {\n const line = raw.trim();\n if (line === '') {\n flush();\n } else if (line.startsWith(WORKTREE_KEY)) {\n // A new record begins — flush the previous one in case the blank line was missing.\n flush();\n path = line.slice(WORKTREE_KEY.length).trim();\n } else if (line.startsWith(BRANCH_KEY)) {\n const ref = line.slice(BRANCH_KEY.length).trim();\n branch = ref.startsWith(REFS_HEADS) ? ref.slice(REFS_HEADS.length) : ref;\n } else if (line === 'prunable' || line.startsWith('prunable ')) {\n prunable = true;\n } else if (line === 'locked' || line.startsWith('locked ')) {\n locked = true;\n }\n }\n flush();\n\n return trees;\n }\n\n // Run a git command capturing trimmed stdout; ok=false on spawn failure or non-zero exit.\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n if (result.status !== 0 || typeof result.stdout !== 'string') return { ok: false, out: '' };\n return { ok: true, out: result.stdout.trim() };\n }\n}\n"]}
|
|
@@ -117,6 +117,28 @@ git worktree prune && git worktree remove ../<feature-dir> && git branch -D <bra
|
|
|
117
117
|
A branch is only ever proposed for deletion when it is backed by a **merged PR** or holds **no commits of
|
|
118
118
|
its own**. Anything else is spared for a human to decide.
|
|
119
119
|
|
|
120
|
+
You also cannot **check out a dead branch into a new worktree**: `git worktree add ../dir <branch>` is
|
|
121
|
+
blocked when that branch's PR is already merged, because the result is a directory full of pre-merge code.
|
|
122
|
+
Base the new worktree on fresh main instead (`git worktree add ../dir -b <new> origin/main`).
|
|
123
|
+
|
|
124
|
+
### A merged worktree is a dead worktree — reads are blocked in it
|
|
125
|
+
|
|
126
|
+
`read-stale-guard` blocks **Read** (not just Write/Edit) once the branch you are standing on has a merged
|
|
127
|
+
PR, in a worktree exactly as in the primary clone. Everything in that tree is a pre-merge snapshot, so any
|
|
128
|
+
plan you build from reading it is built on code `origin/main` has already moved past. The guard prints the
|
|
129
|
+
cure for *the tree you are actually in* — the `git worktree add … origin/main` form in a worktree, the
|
|
130
|
+
`git checkout -b … origin/main` form in the primary clone — plus the prune → remove → delete reap for the
|
|
131
|
+
dead worktree itself. Bash, Write/Edit and reading `webpieces.config.json` all stay allowed, and a **dirty**
|
|
132
|
+
tree fails open so uncommitted work can always be read and rescued.
|
|
133
|
+
|
|
134
|
+
### A fresh worktree has no `node_modules`
|
|
135
|
+
|
|
136
|
+
git does not copy `node_modules` into a new worktree, and the committed hook shim
|
|
137
|
+
(`.claude/webpieces/ai-hook.sh`) resolves the guard binary relative to the tree it lives in. So the first
|
|
138
|
+
tool call in a brand-new worktree fails **closed** with "not installed". That is expected — run
|
|
139
|
+
`pnpm install` **in the worktree** (installing in the primary clone does nothing for it); `pnpm install` is
|
|
140
|
+
one of the few commands the fail-closed shim always allows.
|
|
141
|
+
|
|
120
142
|
## One branch name — local, remote, and PR are always identical
|
|
121
143
|
|
|
122
144
|
A sync does its work on a transient `<feature>Squash` branch and, when it finishes, force-pushes and
|