@webpieces/pr-gate 0.4.721 → 0.4.723

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.
@@ -47,21 +47,38 @@ class WorktreeReapHandoff {
47
47
  /** The linked worktree holding the branch that was just landed. */
48
48
  worktreePath;
49
49
  branch;
50
+ /**
51
+ * That worktree's HEAD — which is, by construction, the exact commit GitHub squashed (see
52
+ * LandedTreeResolver). Carried so the child can re-verify the SELECTION rather than take a path on
53
+ * trust: a branch name is not an identity, and this is the half that makes it one.
54
+ */
55
+ head;
50
56
  /** The primary clone — the child's `cwd`, and the directory a human must `cd` to afterwards. */
51
57
  primaryPath;
52
58
  /** The compiled entry the child runs. '' when it could not be located on disk. */
53
59
  entryScript;
54
60
  /** '' when the hand-off can run; otherwise the reason it cannot, in one human-readable clause. */
55
61
  blockedBecause;
62
+ /**
63
+ * Is the OPERATOR'S OWN shell inside the directory about to be removed?
64
+ *
65
+ * True is the `/full-cycle` case (the agent lands its own PR from its own worktree) and it is the
66
+ * only case where "your cwd no longer exists" is a true sentence worth shouting. False is the
67
+ * coordinator case — `wp-land-pr --pr <n>` from the primary clone — where saying it would send a
68
+ * reader chasing a directory move they do not need to make.
69
+ */
70
+ standingHere;
56
71
  /** Precomputed (as EffectiveTree does with `redirected`) so no caller re-derives the rule. */
57
72
  canReap;
58
73
  // eslint-disable-next-line @typescript-eslint/max-params
59
- constructor(worktreePath, branch, primaryPath, entryScript, blockedBecause) {
74
+ constructor(worktreePath, branch, head, primaryPath, entryScript, blockedBecause, standingHere) {
60
75
  this.worktreePath = worktreePath;
61
76
  this.branch = branch;
77
+ this.head = head;
62
78
  this.primaryPath = primaryPath;
63
79
  this.entryScript = entryScript;
64
80
  this.blockedBecause = blockedBecause;
81
+ this.standingHere = standingHere;
65
82
  this.canReap = blockedBecause === '';
66
83
  }
67
84
  }
@@ -76,25 +93,44 @@ let LandedWorktreeReaper = class LandedWorktreeReaper {
76
93
  /**
77
94
  * Is there a worktree to reap at all, and can the hand-off run?
78
95
  *
79
- * `null` means "nothing to do here" we are in the primary clone, or in a worktree that does not
80
- * hold the branch that just landed. That is the ordinary `pnpm wp-cleanup` case and needs no
81
- * special sentence. A non-null handoff with `canReap === false` means there IS a corpse but the
82
- * re-exec is not safely achievable, which is the case that must keep the manual notice.
96
+ * IT IS TOLD WHICH TREE, AND NO LONGER LOOKS. This used to call `currentWorktree(repoRoot)` and reap
97
+ * only when the tree it was STANDING IN held the landed branch which made the whole #512 mechanism
98
+ * dead code in the one case it was built for, because `pnpm` hoists a bin's cwd out of a nested
99
+ * `.claude/worktrees/**` worktree and into the primary clone, where `isMain` is true and this
100
+ * returned null. Worse, "the tree I am in" was never the right question: a coordinator landing a dead
101
+ * agent's PR is standing somewhere else entirely. LandedTreeResolver answers the right one — which
102
+ * tree's HEAD is the commit GitHub squashed — and hands the answer here.
103
+ *
104
+ * `null` means there is genuinely no worktree to reap: the branch lived only in the primary clone, or
105
+ * nowhere the sha agrees with. That is the ordinary `pnpm wp-cleanup` case and needs no special
106
+ * sentence. A non-null handoff with `canReap === false` means there IS a corpse but the re-exec is
107
+ * not safely achievable, which is the case that must keep the manual notice.
83
108
  */
84
- plan(repoRoot, landedBranch) {
85
- const here = this.worktrees.currentWorktree(repoRoot);
86
- if (here === null || here.isMain || here.branch !== landedBranch)
109
+ plan(repoRoot, landed, invocationCwd) {
110
+ if (landed === null || landed.isMain)
87
111
  return null;
112
+ const standingHere = this.isInside(invocationCwd, landed.path);
88
113
  const primary = this.worktrees.listWorktrees(repoRoot)
89
114
  .find((tree) => tree.isMain);
90
115
  if (primary === undefined) {
91
- return new WorktreeReapHandoff(here.path, landedBranch, '<the primary clone>', '', 'git did not report a primary clone, so there is no safe directory to reap from');
116
+ return new WorktreeReapHandoff(landed.path, landed.branch, landed.head, '<the primary clone>', '', 'git did not report a primary clone, so there is no safe directory to reap from', standingHere);
92
117
  }
93
118
  const entry = this.reapEntryScript();
94
119
  if (entry === '') {
95
- return new WorktreeReapHandoff(here.path, landedBranch, primary.path, '', 'the reap entry point is not on disk (this package is running unbuilt)');
120
+ return new WorktreeReapHandoff(landed.path, landed.branch, landed.head, primary.path, '', 'the reap entry point is not on disk (this package is running unbuilt)', standingHere);
96
121
  }
97
- return new WorktreeReapHandoff(here.path, landedBranch, primary.path, entry, '');
122
+ return new WorktreeReapHandoff(landed.path, landed.branch, landed.head, primary.path, entry, '', standingHere);
123
+ }
124
+ /**
125
+ * Is `cwd` the worktree itself or somewhere beneath it? A path COMPARISON, not a git question:
126
+ * the operator may have been in a subdirectory when they ran the command, and that shell is just as
127
+ * dead once the tree goes. Both sides are resolved first so `.`/`..` and a trailing slash cannot
128
+ * turn a match into a miss.
129
+ */
130
+ isInside(cwd, worktreePath) {
131
+ const from = path.resolve(cwd);
132
+ const tree = path.resolve(worktreePath);
133
+ return from === tree || from.startsWith(tree + path.sep);
98
134
  }
99
135
  /**
100
136
  * Run the reap in a child process rooted in the primary clone, and render what happened.
@@ -109,11 +145,11 @@ let LandedWorktreeReaper = class LandedWorktreeReaper {
109
145
  * ReapOutcomeSignal, and that is the one that gates the "your cwd no longer exists" notice.
110
146
  */
111
147
  handOff(handoff) {
112
- const result = (0, child_process_1.spawnSync)(process.execPath, [handoff.entryScript, handoff.worktreePath, handoff.branch], { cwd: handoff.primaryPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
148
+ const result = (0, child_process_1.spawnSync)(process.execPath, [handoff.entryScript, handoff.worktreePath, handoff.branch, handoff.head], { cwd: handoff.primaryPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
113
149
  const report = this.signal.read(`${result.stdout ?? ''}${result.stderr ?? ''}`);
114
150
  const output = report.text.trimEnd();
115
- const head = '\n' + SEP + `🌲 Reaping this worktree from ${handoff.primaryPath}\n` + SEP + '\n'
116
- + ` The branch just landed, so this directory is dead. The reap runs in a child process\n`
151
+ const head = '\n' + SEP + `🌲 Reaping ${handoff.worktreePath} from ${handoff.primaryPath}\n` + SEP + '\n'
152
+ + ` The branch just landed, so that directory is dead. The reap runs in a child process\n`
117
153
  + ` whose cwd is the primary clone — nothing deletes the directory it is standing in.\n\n`;
118
154
  const body = head + (output !== '' ? output + '\n' : '');
119
155
  if (result.status !== 0 || !report.removed) {
@@ -134,8 +170,8 @@ let LandedWorktreeReaper = class LandedWorktreeReaper {
134
170
  const why = report.outcome === reap_outcome_1.REAP_OUTCOME_MISSING
135
171
  ? 'the child ended without reporting an outcome'
136
172
  : `the child reported '${report.outcome}'`;
137
- return `\n ⚠️ The worktree was NOT removed — ${why}. It is still on disk,\n`
138
- + ' and this shell is still standing in it. Nothing was forced.\n';
173
+ return `\n ⚠️ The worktree was NOT removed — ${why}. It is still on disk.\n`
174
+ + ' Nothing was forced.\n';
139
175
  }
140
176
  /**
141
177
  * The #512 notice, kept verbatim in spirit for every case the re-exec cannot cover. An honest
@@ -143,18 +179,23 @@ let LandedWorktreeReaper = class LandedWorktreeReaper {
143
179
  */
144
180
  manualNotice(handoff) {
145
181
  const why = handoff.blockedBecause !== '' ? ` (${handoff.blockedBecause})\n` : '';
146
- return ' Next: this branch is checked out in THIS worktree, so neither it nor the worktree can\n'
147
- + ' be removed from in here. Run cleanup from the primary clone instead:\n'
182
+ return ` Next: this branch is checked out in ${handoff.worktreePath}, so neither it nor that\n`
183
+ + ' worktree can be removed from here. Run cleanup from the primary clone instead:\n'
148
184
  + ` ${(0, rules_config_1.atRoot)(handoff.primaryPath, 'pnpm wp-cleanup')}\n`
149
185
  + why
150
186
  + ` It archives ${handoff.branch} as a tag, removes ${handoff.worktreePath}, then deletes the branch.\n`;
151
187
  }
152
188
  /**
153
- * The one thing a human or agent MUST be told after a successful reap: the shell they typed this
154
- * into is now sitting in a directory that no longer exists. Every relative path from here on is a
155
- * mystery ENOENT unless they move.
189
+ * The one thing a human or agent MUST be told after a successful reap but only when it is TRUE of
190
+ * them. Landing from inside the tree that just went (the `/full-cycle` case) leaves the shell in a
191
+ * deleted directory and every following relative path is a mystery ENOENT until they move. Landing a
192
+ * dead agent's PR from the primary clone removes somebody ELSE's directory, and telling that operator
193
+ * to `cd` somewhere sends them chasing a move they do not need to make.
156
194
  */
157
195
  afterReap(handoff) {
196
+ if (!handoff.standingHere) {
197
+ return `\n ${handoff.worktreePath} is gone. Your own shell was never inside it.\n`;
198
+ }
158
199
  return `\n ⚠️ ${handoff.worktreePath} NO LONGER EXISTS — your shell is standing in a deleted\n`
159
200
  + ' directory, and every following command will fail until you move:\n'
160
201
  // Single-quoted for the same reason atRoot() quotes: a primary clone under a path with a
@@ -1 +1 @@
1
- {"version":3,"file":"landed-worktree-reaper.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/landed-worktree-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { Worktree, WorktreeService, atRoot } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { ReapOutcomeReport, ReapOutcomeSignal, REAP_OUTCOME_MISSING } from './reap-outcome';\n\n/**\n * The RE-EXEC half of `wp-land-pr`: reap the worktree the command is standing in, from a process that\n * is NOT standing in it.\n *\n * THE PROBLEM. `wp-land-pr` squash-merges the branch checked out in the worktree it runs in. The\n * moment that lands, both the branch and the directory are dead — but removing them from in here means\n * deleting the files underneath the running process, which is exactly what every rail in WorktreeReaper\n * refuses. #512 took this to an honest halfway point: print `cd <primary> && pnpm wp-cleanup` and stop.\n * Correct, and the single most-skipped step in the whole flow, because the PR is already landed and the\n * work FEELS done. Every skipped one leaves a corpse, and corpses are what trip branch-creation-guard.\n *\n * THE FIX. Do not reap in this process at all — hand the reap to a CHILD process whose `cwd` is the\n * PRIMARY CLONE. The child's cwd is a directory nobody is deleting, so `git worktree remove` is an\n * ordinary removal of somebody else's directory, and every existing rail still holds inside the child\n * (it refuses its own cwd, which is now the primary clone, and it refuses the primary clone by name).\n *\n * WHY `WorktreeService` AND NOT `EffectiveTreeResolver`. EffectiveTreeResolver (ai-hook-rules) answers\n * \"which tree does this BASH COMMAND STRING act on\" — it takes a command, resolves its leading `cd`\n * run, and classifies foreign/outside repos for the PreToolUse guards. There is no command string\n * here, pr-gate does not (and must not) depend on ai-hook-rules, and the only question being asked is\n * \"which of git's worktrees is the primary clone, and which one holds this branch\". That is\n * WorktreeService's whole job, and it is already the authority WorktreeReaper itself uses to decide\n * what is protected — using a second resolver would risk the two disagreeing about which tree we are\n * in, which for a directory deletion is the one disagreement nobody survives.\n *\n * WHY A CHILD AND NOT AN IN-PROCESS `chdir`. `process.chdir(primary)` would leave this node process's\n * own module graph rooted in a directory that is about to be deleted, and its lazily-resolved requires\n * with it. The child is spawned from THIS package's own files (same version — no skew with whatever\n * the primary clone has installed), and it finishes loading them before it removes anything.\n */\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * Data-only (per CLAUDE.md, classes for data). The decision about the just-landed worktree: which\n * directory, which branch, where the reap must run FROM, and — when it cannot run — why not.\n */\nexport class WorktreeReapHandoff {\n /** The linked worktree holding the branch that was just landed. */\n readonly worktreePath: string;\n readonly branch: string;\n /** The primary clone — the child's `cwd`, and the directory a human must `cd` to afterwards. */\n readonly primaryPath: string;\n /** The compiled entry the child runs. '' when it could not be located on disk. */\n readonly entryScript: string;\n /** '' when the hand-off can run; otherwise the reason it cannot, in one human-readable clause. */\n readonly blockedBecause: string;\n /** Precomputed (as EffectiveTree does with `redirected`) so no caller re-derives the rule. */\n readonly canReap: boolean;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n worktreePath: string,\n branch: string,\n primaryPath: string,\n entryScript: string,\n blockedBecause: string,\n ) {\n this.worktreePath = worktreePath;\n this.branch = branch;\n this.primaryPath = primaryPath;\n this.entryScript = entryScript;\n this.blockedBecause = blockedBecause;\n this.canReap = blockedBecause === '';\n }\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class LandedWorktreeReaper {\n constructor(\n private readonly worktrees: WorktreeService,\n private readonly signal: ReapOutcomeSignal,\n ) {}\n\n /**\n * Is there a worktree to reap at all, and can the hand-off run?\n *\n * `null` means \"nothing to do here\" — we are in the primary clone, or in a worktree that does not\n * hold the branch that just landed. That is the ordinary `pnpm wp-cleanup` case and needs no\n * special sentence. A non-null handoff with `canReap === false` means there IS a corpse but the\n * re-exec is not safely achievable, which is the case that must keep the manual notice.\n */\n plan(repoRoot: string, landedBranch: string): WorktreeReapHandoff | null {\n const here = this.worktrees.currentWorktree(repoRoot);\n if (here === null || here.isMain || here.branch !== landedBranch) return null;\n\n const primary = this.worktrees.listWorktrees(repoRoot)\n .find((tree: Worktree): boolean => tree.isMain);\n if (primary === undefined) {\n return new WorktreeReapHandoff(\n here.path, landedBranch, '<the primary clone>', '',\n 'git did not report a primary clone, so there is no safe directory to reap from');\n }\n\n const entry = this.reapEntryScript();\n if (entry === '') {\n return new WorktreeReapHandoff(\n here.path, landedBranch, primary.path, '',\n 'the reap entry point is not on disk (this package is running unbuilt)');\n }\n return new WorktreeReapHandoff(here.path, landedBranch, primary.path, entry, '');\n }\n\n /**\n * Run the reap in a child process rooted in the primary clone, and render what happened.\n *\n * stdin is `ignore` deliberately: the child must never be able to ask a question. A prompt printed\n * into a landing recap nobody is watching is not consent, and this reap is authorised by the merge\n * that just succeeded, not by an answer.\n *\n * THE EXIT CODE IS NOT THE ANSWER. The child refuses by PRINTING and exiting 0 on purpose — a\n * non-zero exit after a successful merge would report a landed PR as a failed command. So `exit 0`\n * only means \"the child ran\"; whether the DIRECTORY is gone is a separate statement it makes through\n * ReapOutcomeSignal, and that is the one that gates the \"your cwd no longer exists\" notice.\n */\n handOff(handoff: WorktreeReapHandoff): string {\n const result = spawnSync(\n process.execPath, [handoff.entryScript, handoff.worktreePath, handoff.branch],\n { cwd: handoff.primaryPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });\n\n const report = this.signal.read(`${result.stdout ?? ''}${result.stderr ?? ''}`);\n const output = report.text.trimEnd();\n const head = '\\n' + SEP + `🌲 Reaping this worktree from ${handoff.primaryPath}\\n` + SEP + '\\n'\n + ` The branch just landed, so this directory is dead. The reap runs in a child process\\n`\n + ` whose cwd is the primary clone — nothing deletes the directory it is standing in.\\n\\n`;\n const body = head + (output !== '' ? output + '\\n' : '');\n\n if (result.status !== 0 || !report.removed) {\n return body + this.notRemoved(result.status, report) + this.manualNotice(handoff);\n }\n return body + this.afterReap(handoff);\n }\n\n /**\n * WHY the directory is still there, distinguishing the two cases a single exit code conflated: the\n * child broke (non-zero), versus the child worked perfectly and DECLINED (exit 0, outcome refused).\n * `--force` is absent from both, and from every path below them: git refusing to remove a worktree\n * holding untracked files is work no archive tag captured.\n */\n private notRemoved(status: number | null, report: ReapOutcomeReport): string {\n if (status !== 0) {\n return `\\n ⚠️ The reap did not complete (exit ${String(status ?? -1)}). Nothing was forced.\\n`;\n }\n const why = report.outcome === REAP_OUTCOME_MISSING\n ? 'the child ended without reporting an outcome'\n : `the child reported '${report.outcome}'`;\n return `\\n ⚠️ The worktree was NOT removed — ${why}. It is still on disk,\\n`\n + ' and this shell is still standing in it. Nothing was forced.\\n';\n }\n\n /**\n * The #512 notice, kept verbatim in spirit for every case the re-exec cannot cover. An honest\n * limitation beats a command that deletes its own working directory mid-run.\n */\n manualNotice(handoff: WorktreeReapHandoff): string {\n const why = handoff.blockedBecause !== '' ? ` (${handoff.blockedBecause})\\n` : '';\n return ' Next: this branch is checked out in THIS worktree, so neither it nor the worktree can\\n'\n + ' be removed from in here. Run cleanup from the primary clone instead:\\n'\n + ` ${atRoot(handoff.primaryPath, 'pnpm wp-cleanup')}\\n`\n + why\n + ` It archives ${handoff.branch} as a tag, removes ${handoff.worktreePath}, then deletes the branch.\\n`;\n }\n\n /**\n * The one thing a human or agent MUST be told after a successful reap: the shell they typed this\n * into is now sitting in a directory that no longer exists. Every relative path from here on is a\n * mystery ENOENT unless they move.\n */\n private afterReap(handoff: WorktreeReapHandoff): string {\n return `\\n ⚠️ ${handoff.worktreePath} NO LONGER EXISTS — your shell is standing in a deleted\\n`\n + ' directory, and every following command will fail until you move:\\n'\n // Single-quoted for the same reason atRoot() quotes: a primary clone under a path with a\n // space (`/Users/dean hiller/…`, \"Google Drive\", iCloud) makes a bare `cd` two arguments.\n + ` cd '${handoff.primaryPath}'\\n`;\n }\n\n /**\n * WHERE the child's entry point lives — a seam, overridden in the spec, because under vitest this\n * package runs from `.ts` sources and the compiled sibling does not exist.\n *\n * Resolved from `__dirname`, i.e. from THIS package's own installed files, NOT by remapping a path\n * into the primary clone. The primary clone may have a different @webpieces/pr-gate version\n * installed, and re-exec'ing a build that predates this feature would silently do something else.\n * The files are read into the child before it removes anything, so the directory going away\n * underneath them afterwards is harmless.\n */\n protected reapEntryScript(): string {\n const entry = path.join(__dirname, '..', 'wp-reap-worktree.js');\n return fs.existsSync(entry) ? entry : '';\n }\n}\n"]}
1
+ {"version":3,"file":"landed-worktree-reaper.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/landed-worktree-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { Worktree, WorktreeService, atRoot } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { ReapOutcomeReport, ReapOutcomeSignal, REAP_OUTCOME_MISSING } from './reap-outcome';\n\n/**\n * The RE-EXEC half of `wp-land-pr`: reap the worktree the command is standing in, from a process that\n * is NOT standing in it.\n *\n * THE PROBLEM. `wp-land-pr` squash-merges the branch checked out in the worktree it runs in. The\n * moment that lands, both the branch and the directory are dead — but removing them from in here means\n * deleting the files underneath the running process, which is exactly what every rail in WorktreeReaper\n * refuses. #512 took this to an honest halfway point: print `cd <primary> && pnpm wp-cleanup` and stop.\n * Correct, and the single most-skipped step in the whole flow, because the PR is already landed and the\n * work FEELS done. Every skipped one leaves a corpse, and corpses are what trip branch-creation-guard.\n *\n * THE FIX. Do not reap in this process at all — hand the reap to a CHILD process whose `cwd` is the\n * PRIMARY CLONE. The child's cwd is a directory nobody is deleting, so `git worktree remove` is an\n * ordinary removal of somebody else's directory, and every existing rail still holds inside the child\n * (it refuses its own cwd, which is now the primary clone, and it refuses the primary clone by name).\n *\n * WHY `WorktreeService` AND NOT `EffectiveTreeResolver`. EffectiveTreeResolver (ai-hook-rules) answers\n * \"which tree does this BASH COMMAND STRING act on\" — it takes a command, resolves its leading `cd`\n * run, and classifies foreign/outside repos for the PreToolUse guards. There is no command string\n * here, pr-gate does not (and must not) depend on ai-hook-rules, and the only question being asked is\n * \"which of git's worktrees is the primary clone, and which one holds this branch\". That is\n * WorktreeService's whole job, and it is already the authority WorktreeReaper itself uses to decide\n * what is protected — using a second resolver would risk the two disagreeing about which tree we are\n * in, which for a directory deletion is the one disagreement nobody survives.\n *\n * WHY A CHILD AND NOT AN IN-PROCESS `chdir`. `process.chdir(primary)` would leave this node process's\n * own module graph rooted in a directory that is about to be deleted, and its lazily-resolved requires\n * with it. The child is spawned from THIS package's own files (same version — no skew with whatever\n * the primary clone has installed), and it finishes loading them before it removes anything.\n */\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * Data-only (per CLAUDE.md, classes for data). The decision about the just-landed worktree: which\n * directory, which branch, where the reap must run FROM, and — when it cannot run — why not.\n */\nexport class WorktreeReapHandoff {\n /** The linked worktree holding the branch that was just landed. */\n readonly worktreePath: string;\n readonly branch: string;\n /**\n * That worktree's HEAD — which is, by construction, the exact commit GitHub squashed (see\n * LandedTreeResolver). Carried so the child can re-verify the SELECTION rather than take a path on\n * trust: a branch name is not an identity, and this is the half that makes it one.\n */\n readonly head: string;\n /** The primary clone — the child's `cwd`, and the directory a human must `cd` to afterwards. */\n readonly primaryPath: string;\n /** The compiled entry the child runs. '' when it could not be located on disk. */\n readonly entryScript: string;\n /** '' when the hand-off can run; otherwise the reason it cannot, in one human-readable clause. */\n readonly blockedBecause: string;\n /**\n * Is the OPERATOR'S OWN shell inside the directory about to be removed?\n *\n * True is the `/full-cycle` case (the agent lands its own PR from its own worktree) and it is the\n * only case where \"your cwd no longer exists\" is a true sentence worth shouting. False is the\n * coordinator case — `wp-land-pr --pr <n>` from the primary clone — where saying it would send a\n * reader chasing a directory move they do not need to make.\n */\n readonly standingHere: boolean;\n /** Precomputed (as EffectiveTree does with `redirected`) so no caller re-derives the rule. */\n readonly canReap: boolean;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n worktreePath: string,\n branch: string,\n head: string,\n primaryPath: string,\n entryScript: string,\n blockedBecause: string,\n standingHere: boolean,\n ) {\n this.worktreePath = worktreePath;\n this.branch = branch;\n this.head = head;\n this.primaryPath = primaryPath;\n this.entryScript = entryScript;\n this.blockedBecause = blockedBecause;\n this.standingHere = standingHere;\n this.canReap = blockedBecause === '';\n }\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class LandedWorktreeReaper {\n constructor(\n private readonly worktrees: WorktreeService,\n private readonly signal: ReapOutcomeSignal,\n ) {}\n\n /**\n * Is there a worktree to reap at all, and can the hand-off run?\n *\n * IT IS TOLD WHICH TREE, AND NO LONGER LOOKS. This used to call `currentWorktree(repoRoot)` and reap\n * only when the tree it was STANDING IN held the landed branch — which made the whole #512 mechanism\n * dead code in the one case it was built for, because `pnpm` hoists a bin's cwd out of a nested\n * `.claude/worktrees/**` worktree and into the primary clone, where `isMain` is true and this\n * returned null. Worse, \"the tree I am in\" was never the right question: a coordinator landing a dead\n * agent's PR is standing somewhere else entirely. LandedTreeResolver answers the right one — which\n * tree's HEAD is the commit GitHub squashed — and hands the answer here.\n *\n * `null` means there is genuinely no worktree to reap: the branch lived only in the primary clone, or\n * nowhere the sha agrees with. That is the ordinary `pnpm wp-cleanup` case and needs no special\n * sentence. A non-null handoff with `canReap === false` means there IS a corpse but the re-exec is\n * not safely achievable, which is the case that must keep the manual notice.\n */\n plan(repoRoot: string, landed: Worktree | null, invocationCwd: string): WorktreeReapHandoff | null {\n if (landed === null || landed.isMain) return null;\n const standingHere = this.isInside(invocationCwd, landed.path);\n\n const primary = this.worktrees.listWorktrees(repoRoot)\n .find((tree: Worktree): boolean => tree.isMain);\n if (primary === undefined) {\n return new WorktreeReapHandoff(\n landed.path, landed.branch, landed.head, '<the primary clone>', '',\n 'git did not report a primary clone, so there is no safe directory to reap from',\n standingHere);\n }\n\n const entry = this.reapEntryScript();\n if (entry === '') {\n return new WorktreeReapHandoff(\n landed.path, landed.branch, landed.head, primary.path, '',\n 'the reap entry point is not on disk (this package is running unbuilt)', standingHere);\n }\n return new WorktreeReapHandoff(\n landed.path, landed.branch, landed.head, primary.path, entry, '', standingHere);\n }\n\n /**\n * Is `cwd` the worktree itself or somewhere beneath it? A path COMPARISON, not a git question:\n * the operator may have been in a subdirectory when they ran the command, and that shell is just as\n * dead once the tree goes. Both sides are resolved first so `.`/`..` and a trailing slash cannot\n * turn a match into a miss.\n */\n private isInside(cwd: string, worktreePath: string): boolean {\n const from = path.resolve(cwd);\n const tree = path.resolve(worktreePath);\n return from === tree || from.startsWith(tree + path.sep);\n }\n\n /**\n * Run the reap in a child process rooted in the primary clone, and render what happened.\n *\n * stdin is `ignore` deliberately: the child must never be able to ask a question. A prompt printed\n * into a landing recap nobody is watching is not consent, and this reap is authorised by the merge\n * that just succeeded, not by an answer.\n *\n * THE EXIT CODE IS NOT THE ANSWER. The child refuses by PRINTING and exiting 0 on purpose — a\n * non-zero exit after a successful merge would report a landed PR as a failed command. So `exit 0`\n * only means \"the child ran\"; whether the DIRECTORY is gone is a separate statement it makes through\n * ReapOutcomeSignal, and that is the one that gates the \"your cwd no longer exists\" notice.\n */\n handOff(handoff: WorktreeReapHandoff): string {\n const result = spawnSync(\n process.execPath, [handoff.entryScript, handoff.worktreePath, handoff.branch, handoff.head],\n { cwd: handoff.primaryPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });\n\n const report = this.signal.read(`${result.stdout ?? ''}${result.stderr ?? ''}`);\n const output = report.text.trimEnd();\n const head = '\\n' + SEP + `🌲 Reaping ${handoff.worktreePath} from ${handoff.primaryPath}\\n` + SEP + '\\n'\n + ` The branch just landed, so that directory is dead. The reap runs in a child process\\n`\n + ` whose cwd is the primary clone — nothing deletes the directory it is standing in.\\n\\n`;\n const body = head + (output !== '' ? output + '\\n' : '');\n\n if (result.status !== 0 || !report.removed) {\n return body + this.notRemoved(result.status, report) + this.manualNotice(handoff);\n }\n return body + this.afterReap(handoff);\n }\n\n /**\n * WHY the directory is still there, distinguishing the two cases a single exit code conflated: the\n * child broke (non-zero), versus the child worked perfectly and DECLINED (exit 0, outcome refused).\n * `--force` is absent from both, and from every path below them: git refusing to remove a worktree\n * holding untracked files is work no archive tag captured.\n */\n private notRemoved(status: number | null, report: ReapOutcomeReport): string {\n if (status !== 0) {\n return `\\n ⚠️ The reap did not complete (exit ${String(status ?? -1)}). Nothing was forced.\\n`;\n }\n const why = report.outcome === REAP_OUTCOME_MISSING\n ? 'the child ended without reporting an outcome'\n : `the child reported '${report.outcome}'`;\n return `\\n ⚠️ The worktree was NOT removed — ${why}. It is still on disk.\\n`\n + ' Nothing was forced.\\n';\n }\n\n /**\n * The #512 notice, kept verbatim in spirit for every case the re-exec cannot cover. An honest\n * limitation beats a command that deletes its own working directory mid-run.\n */\n manualNotice(handoff: WorktreeReapHandoff): string {\n const why = handoff.blockedBecause !== '' ? ` (${handoff.blockedBecause})\\n` : '';\n return ` Next: this branch is checked out in ${handoff.worktreePath}, so neither it nor that\\n`\n + ' worktree can be removed from here. Run cleanup from the primary clone instead:\\n'\n + ` ${atRoot(handoff.primaryPath, 'pnpm wp-cleanup')}\\n`\n + why\n + ` It archives ${handoff.branch} as a tag, removes ${handoff.worktreePath}, then deletes the branch.\\n`;\n }\n\n /**\n * The one thing a human or agent MUST be told after a successful reap — but only when it is TRUE of\n * them. Landing from inside the tree that just went (the `/full-cycle` case) leaves the shell in a\n * deleted directory and every following relative path is a mystery ENOENT until they move. Landing a\n * dead agent's PR from the primary clone removes somebody ELSE's directory, and telling that operator\n * to `cd` somewhere sends them chasing a move they do not need to make.\n */\n private afterReap(handoff: WorktreeReapHandoff): string {\n if (!handoff.standingHere) {\n return `\\n ${handoff.worktreePath} is gone. Your own shell was never inside it.\\n`;\n }\n return `\\n ⚠️ ${handoff.worktreePath} NO LONGER EXISTS — your shell is standing in a deleted\\n`\n + ' directory, and every following command will fail until you move:\\n'\n // Single-quoted for the same reason atRoot() quotes: a primary clone under a path with a\n // space (`/Users/dean hiller/…`, \"Google Drive\", iCloud) makes a bare `cd` two arguments.\n + ` cd '${handoff.primaryPath}'\\n`;\n }\n\n /**\n * WHERE the child's entry point lives — a seam, overridden in the spec, because under vitest this\n * package runs from `.ts` sources and the compiled sibling does not exist.\n *\n * Resolved from `__dirname`, i.e. from THIS package's own installed files, NOT by remapping a path\n * into the primary clone. The primary clone may have a different @webpieces/pr-gate version\n * installed, and re-exec'ing a build that predates this feature would silently do something else.\n * The files are read into the child before it removes anything, so the directory going away\n * underneath them afterwards is harmless.\n */\n protected reapEntryScript(): string {\n const entry = path.join(__dirname, '..', 'wp-reap-worktree.js');\n return fs.existsSync(entry) ? entry : '';\n }\n}\n"]}
@@ -4,17 +4,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  require("reflect-metadata");
5
5
  const inversify_1 = require("inversify");
6
6
  const rules_config_1 = require("@webpieces/rules-config");
7
+ const land_pr_command_1 = require("./commands/land-pr-command");
7
8
  const pr_gate_app_1 = require("./pr-gate-app");
9
+ const PR_FLAG = '--pr';
8
10
  // Composition root: build the container and resolve the app so inversify constructs the whole DAG.
9
11
  (0, rules_config_1.runMain)(async () => {
10
12
  // autobind self-binds every @injectable(Singleton) tooling class (replaces the buildProviderModule registry scan)
11
13
  const container = new inversify_1.Container({ autobind: true });
12
- // Reject `--help`/bogus flags BEFORE the app touches git. This command takes NO flags: the commit
13
- // body is the PR's own description, read back from GitHub, so there is nothing left for a human to
14
- // choose. `--fallback-title-only` was deleted with the machine-global receipt store that made it
15
- // necessary see decisions/0005 and a mistyped flag must still refuse rather than be ignored on a
16
- // command that writes main's history.
17
- container.get(rules_config_1.CliArgs).parse(new rules_config_1.CliUsage('wp-land-pr', "Squash-merge this branch's PR into main with its description as the commit body.", []));
18
- await container.get(pr_gate_app_1.PrGateApp).landPr();
14
+ // Reject `--help`/bogus flags BEFORE the app touches git. A mistyped flag must still refuse rather
15
+ // than be ignored on a command that writes main's history.
16
+ //
17
+ // `--pr <n>` is the ONE flag, and it does not choose anything about the COMMIT: the body is still the
18
+ // PR's own description read back from GitHub (`--fallback-title-only` was deleted with the
19
+ // machine-global receipt store that made it necessary see decisions/0005). It chooses WHICH PR,
20
+ // which is a question the zero-arg form can only answer for whoever is standing on the branch. Most
21
+ // of the time the agent that built the branch lands it; many times it does not — CI was still running
22
+ // when it finished, it errored, or a coordinator picks the work up an hour later — and by then that
23
+ // agent is gone. This is how the coordinator finishes the job, bookkeeping included.
24
+ const args = container.get(rules_config_1.CliArgs).parse(new rules_config_1.CliUsage('wp-land-pr', "Squash-merge a PR into main with its description as the commit body.", [new rules_config_1.CliFlag(PR_FLAG, "The PR number to land. Omit for the PR of the branch you are standing on.", true)]));
25
+ await container.get(pr_gate_app_1.PrGateApp).landPr(new land_pr_command_1.LandPrRequest(args.has(PR_FLAG), args.value(PR_FLAG)));
19
26
  });
20
27
  //# sourceMappingURL=wp-land-pr.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"wp-land-pr.js","sourceRoot":"","sources":["../../../../../../packages/tooling/pr-gate/src/scripts/wp-land-pr.ts"],"names":[],"mappings":";;;AACA,4BAA0B;AAC1B,yCAAsC;AACtC,0DAAqE;AACrE,+CAA0C;AAE1C,mGAAmG;AACnG,IAAA,sBAAO,EAAC,KAAK,IAAmB,EAAE;IAC9B,kHAAkH;IAClH,MAAM,SAAS,GAAG,IAAI,qBAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;IACpD,kGAAkG;IAClG,mGAAmG;IACnG,iGAAiG;IACjG,qGAAqG;IACrG,sCAAsC;IACtC,SAAS,CAAC,GAAG,CAAC,sBAAO,CAAC,CAAC,KAAK,CAAC,IAAI,uBAAQ,CACrC,YAAY,EACZ,kFAAkF,EAClF,EAAE,CACL,CAAC,CAAC;IACH,MAAM,SAAS,CAAC,GAAG,CAAC,uBAAS,CAAC,CAAC,MAAM,EAAE,CAAC;AAC5C,CAAC,CAAC,CAAC","sourcesContent":["#!/usr/bin/env node\nimport 'reflect-metadata';\nimport { Container } from 'inversify';\nimport { runMain, CliArgs, CliUsage } from '@webpieces/rules-config';\nimport { PrGateApp } from './pr-gate-app';\n\n// Composition root: build the container and resolve the app so inversify constructs the whole DAG.\nrunMain(async (): Promise<void> => {\n // autobind self-binds every @injectable(Singleton) tooling class (replaces the buildProviderModule registry scan)\n const container = new Container({ autobind: true });\n // Reject `--help`/bogus flags BEFORE the app touches git. This command takes NO flags: the commit\n // body is the PR's own description, read back from GitHub, so there is nothing left for a human to\n // choose. `--fallback-title-only` was deleted with the machine-global receipt store that made it\n // necessary — see decisions/0005 and a mistyped flag must still refuse rather than be ignored on a\n // command that writes main's history.\n container.get(CliArgs).parse(new CliUsage(\n 'wp-land-pr',\n \"Squash-merge this branch's PR into main with its description as the commit body.\",\n [],\n ));\n await container.get(PrGateApp).landPr();\n});\n"]}
1
+ {"version":3,"file":"wp-land-pr.js","sourceRoot":"","sources":["../../../../../../packages/tooling/pr-gate/src/scripts/wp-land-pr.ts"],"names":[],"mappings":";;;AACA,4BAA0B;AAC1B,yCAAsC;AACtC,0DAA8E;AAC9E,gEAA2D;AAC3D,+CAA0C;AAE1C,MAAM,OAAO,GAAG,MAAM,CAAC;AAEvB,mGAAmG;AACnG,IAAA,sBAAO,EAAC,KAAK,IAAmB,EAAE;IAC9B,kHAAkH;IAClH,MAAM,SAAS,GAAG,IAAI,qBAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;IACpD,mGAAmG;IACnG,2DAA2D;IAC3D,EAAE;IACF,sGAAsG;IACtG,2FAA2F;IAC3F,kGAAkG;IAClG,oGAAoG;IACpG,sGAAsG;IACtG,oGAAoG;IACpG,qFAAqF;IACrF,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,sBAAO,CAAC,CAAC,KAAK,CAAC,IAAI,uBAAQ,CAClD,YAAY,EACZ,sEAAsE,EACtE,CAAC,IAAI,sBAAO,CAAC,OAAO,EAAE,2EAA2E,EAAE,IAAI,CAAC,CAAC,CAC5G,CAAC,CAAC;IACH,MAAM,SAAS,CAAC,GAAG,CAAC,uBAAS,CAAC,CAAC,MAAM,CAAC,IAAI,+BAAa,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AACrG,CAAC,CAAC,CAAC","sourcesContent":["#!/usr/bin/env node\nimport 'reflect-metadata';\nimport { Container } from 'inversify';\nimport { runMain, CliArgs, CliFlag, CliUsage } from '@webpieces/rules-config';\nimport { LandPrRequest } from './commands/land-pr-command';\nimport { PrGateApp } from './pr-gate-app';\n\nconst PR_FLAG = '--pr';\n\n// Composition root: build the container and resolve the app so inversify constructs the whole DAG.\nrunMain(async (): Promise<void> => {\n // autobind self-binds every @injectable(Singleton) tooling class (replaces the buildProviderModule registry scan)\n const container = new Container({ autobind: true });\n // Reject `--help`/bogus flags BEFORE the app touches git. A mistyped flag must still refuse rather\n // than be ignored on a command that writes main's history.\n //\n // `--pr <n>` is the ONE flag, and it does not choose anything about the COMMIT: the body is still the\n // PR's own description read back from GitHub (`--fallback-title-only` was deleted with the\n // machine-global receipt store that made it necessary — see decisions/0005). It chooses WHICH PR,\n // which is a question the zero-arg form can only answer for whoever is standing on the branch. Most\n // of the time the agent that built the branch lands it; many times it does not — CI was still running\n // when it finished, it errored, or a coordinator picks the work up an hour later — and by then that\n // agent is gone. This is how the coordinator finishes the job, bookkeeping included.\n const args = container.get(CliArgs).parse(new CliUsage(\n 'wp-land-pr',\n \"Squash-merge a PR into main with its description as the commit body.\",\n [new CliFlag(PR_FLAG, \"The PR number to land. Omit for the PR of the branch you are standing on.\", true)],\n ));\n await container.get(PrGateApp).landPr(new LandPrRequest(args.has(PR_FLAG), args.value(PR_FLAG)));\n});\n"]}
@@ -12,9 +12,10 @@ const pr_gate_app_1 = require("./pr-gate-app");
12
12
  * worktree it just landed from can be removed by a process that is not standing in it. There is no
13
13
  * `wp-reap-worktree` verb for a human: `pnpm wp-cleanup` is that verb and does strictly more.
14
14
  *
15
- * Unlike its siblings it TAKES arguments (`<worktree-path> <branch>`), so `CliArgs.assertNoArgs` —
16
- * which exists to stop a stray flag from silently starting a mutation flow — would be wrong here.
17
- * ReapWorktreeCommand validates argv itself and refuses to do anything without both values.
15
+ * Unlike its siblings it TAKES arguments (`<worktree-path> <branch> <head-sha>`), so
16
+ * `CliArgs.assertNoArgs` — which exists to stop a stray flag from silently starting a mutation flow —
17
+ * would be wrong here. ReapWorktreeCommand validates argv itself and refuses to do anything without all
18
+ * three values; the sha is what stops a removal being decided by a branch NAME.
18
19
  */
19
20
  (0, rules_config_1.runMain)(async () => {
20
21
  const container = new inversify_1.Container({ autobind: true });
@@ -1 +1 @@
1
- {"version":3,"file":"wp-reap-worktree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/pr-gate/src/scripts/wp-reap-worktree.ts"],"names":[],"mappings":";;;AACA,4BAA0B;AAC1B,yCAAsC;AACtC,0DAAkD;AAClD,+CAA0C;AAE1C;;;;;;;;;;GAUG;AACH,IAAA,sBAAO,EAAC,KAAK,IAAmB,EAAE;IAC9B,MAAM,SAAS,GAAG,IAAI,qBAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;IACpD,MAAM,SAAS,CAAC,GAAG,CAAC,uBAAS,CAAC,CAAC,YAAY,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;AACvE,CAAC,CAAC,CAAC","sourcesContent":["#!/usr/bin/env node\nimport 'reflect-metadata';\nimport { Container } from 'inversify';\nimport { runMain } from '@webpieces/rules-config';\nimport { PrGateApp } from './pr-gate-app';\n\n/**\n * INTERNAL entry point — deliberately NOT registered in package.json `bin`.\n *\n * `pnpm wp-land-pr` spawns this by absolute path with `cwd` set to the PRIMARY CLONE, so that the\n * worktree it just landed from can be removed by a process that is not standing in it. There is no\n * `wp-reap-worktree` verb for a human: `pnpm wp-cleanup` is that verb and does strictly more.\n *\n * Unlike its siblings it TAKES arguments (`<worktree-path> <branch>`), so `CliArgs.assertNoArgs` —\n * which exists to stop a stray flag from silently starting a mutation flow would be wrong here.\n * ReapWorktreeCommand validates argv itself and refuses to do anything without both values.\n */\nrunMain(async (): Promise<void> => {\n const container = new Container({ autobind: true });\n await container.get(PrGateApp).reapWorktree(process.argv.slice(2));\n});\n"]}
1
+ {"version":3,"file":"wp-reap-worktree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/pr-gate/src/scripts/wp-reap-worktree.ts"],"names":[],"mappings":";;;AACA,4BAA0B;AAC1B,yCAAsC;AACtC,0DAAkD;AAClD,+CAA0C;AAE1C;;;;;;;;;;;GAWG;AACH,IAAA,sBAAO,EAAC,KAAK,IAAmB,EAAE;IAC9B,MAAM,SAAS,GAAG,IAAI,qBAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;IACpD,MAAM,SAAS,CAAC,GAAG,CAAC,uBAAS,CAAC,CAAC,YAAY,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;AACvE,CAAC,CAAC,CAAC","sourcesContent":["#!/usr/bin/env node\nimport 'reflect-metadata';\nimport { Container } from 'inversify';\nimport { runMain } from '@webpieces/rules-config';\nimport { PrGateApp } from './pr-gate-app';\n\n/**\n * INTERNAL entry point — deliberately NOT registered in package.json `bin`.\n *\n * `pnpm wp-land-pr` spawns this by absolute path with `cwd` set to the PRIMARY CLONE, so that the\n * worktree it just landed from can be removed by a process that is not standing in it. There is no\n * `wp-reap-worktree` verb for a human: `pnpm wp-cleanup` is that verb and does strictly more.\n *\n * Unlike its siblings it TAKES arguments (`<worktree-path> <branch> <head-sha>`), so\n * `CliArgs.assertNoArgs` which exists to stop a stray flag from silently starting a mutation flow —\n * would be wrong here. ReapWorktreeCommand validates argv itself and refuses to do anything without all\n * three values; the sha is what stops a removal being decided by a branch NAME.\n */\nrunMain(async (): Promise<void> => {\n const container = new Container({ autobind: true });\n await container.get(PrGateApp).reapWorktree(process.argv.slice(2));\n});\n"]}