@signalridge/pi-worktree 0.49.3 → 1.2.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0
4
+ ### Major Changes
5
+
6
+ - Adopt a unified 1.0.0 across every published package.
7
+
8
+ The version numbers no longer track their upstream forks individually; from this release each package
9
+ is versioned on its own merit against the Signalridge line, and 1.0.0 is the shared starting point.
10
+ Packages whose behavior changed in this release document that change in their own changeset entries;
11
+ the remainder are re-released unchanged so the whole set shares one baseline.
12
+
13
+ ### Patch Changes
14
+
15
+ - Strip Unicode bidirectional overrides — and DCS/PM/APC payloads in the footer and recall picker — from untrusted text before display, so a crafted branch name, worktree path, model id, or recalled message can no longer visually reverse what the user reads and acts on.
16
+ - Wire the hardened terminal sanitizers to the fields that actually carry hostile text, bound the escape-sequence skippers, and give worktree Add base-commit provenance.
17
+
18
+ - `pi-statusline`: the `branch`, `provider`, and `tools` segments now pass through `sanitizeTerminalText`. The branch is sanitized before the GitHub PR context is composed in, so a hostile ref name can no longer emit raw OSC/CSI/bidi into the footer while the PR hyperlink keeps working.
19
+ - `pi-statusline` / `pi-recall`: an escape-sequence introducer with no terminator now drops only itself instead of consuming the rest of the string, so a single `0x90` in a branch name or saved message no longer blanks a footer row, a preview, or the fuzzy-search text. SOS is handled alongside DCS/PM/APC.
20
+ - `pi-recall`: previews are sanitized before truncation, so the preview budget is spent on visible characters instead of invisible escape bytes.
21
+ - `pi-worktree`: line separators are replaced with a space instead of being dropped, so multi-line Git output no longer welds two records together inside a destructive confirmation body.
22
+ - `pi-worktree`: Add resolves the base commit for both the create and the attach case (the attach case previously captured none, letting Git re-resolve the branch at exec time), shows the full base OID in the confirmation, and re-reads `git worktree list`, the local branch ref, and the base OID under the worktree mutation lock immediately before `git worktree add`. A base that moved, or a branch or path claimed while the dialog was open, refuses the add with a message naming both OIDs instead of silently creating a worktree from an unapproved base.
23
+
24
+ ## 0.49.4
25
+ ### Patch Changes
26
+
27
+ - 4c50252: Harden destructive worktree removal: refuse ignored local data, quarantine through Git's worktree-aware move, verify tree identity before each deletion, retain concurrent replacements, and isolate Git metadata pruning so cleanup cannot recursively delete late-created files.
28
+
3
29
  ## Unreleased
4
30
 
5
31
  ### Fixed
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  Pi cannot change its parent process working directory with `cd`. This extension performs the safe equivalent: it prepares a Pi session whose cwd is the selected worktree and switches to that session, preserving the current conversation when it has already been persisted.
8
8
 
9
- This package is the full interactive worktree manager. It is distinct from the signalridge `pi-worktree-guard` extension, which only guards unsafe worktree-related operations and does not create, remove, prune, or switch worktrees. `pi-worktree` is publishable and stable, but it is not automatically enabled by the signalridge dotfiles configuration: the user's AGENTS constitution forbids worktrees.
9
+ This package is the full interactive worktree manager. It is publishable and stable, but it is not automatically enabled by the signalridge dotfiles configuration: the user's AGENTS constitution forbids worktrees.
10
10
 
11
11
  ## ✨ Features
12
12
 
@@ -52,7 +52,7 @@ Run the command without arguments:
52
52
 
53
53
  Choose one action:
54
54
 
55
- - **Add worktree** — enter a branch, optional start point, and optional path; confirm creation and optionally switch.
55
+ - **Add worktree** — enter a branch, optional start point, and optional path; the confirmation names the branch, base ref, full base commit, and target path before creation, then optionally switch.
56
56
  - **Switch worktree** — select another existing worktree and continue this Pi conversation there.
57
57
  - **Remove worktree** — remove a linked worktree without deleting its branch; local ignored data must be removed first.
58
58
  - **Prune stale metadata** — inspect Git's dry-run output, then optionally run the matching prune.
@@ -129,8 +129,10 @@ A successfully created Git worktree is never rolled back merely because Pi sessi
129
129
  - Removal and prune inspect reflogs, pseudorefs, per-worktree refs, and `FETCH_HEAD`. Historical commits reachable only through this administrative recovery state are listed by full OID in the destructive confirmation; approval removes those recovery pointers, so Git may later garbage-collect the commits. Create a branch or tag instead when any listed commit should survive.
130
130
  - Staged-only administrative index state, a missing attached branch ref, or an unreachable current detached HEAD still blocks prune without an override.
131
131
  - Removal never deletes a branch and never uses `--force`.
132
+ - Worktree paths, branches, and lock reasons are stripped of terminal control characters and bidirectional overrides before display, so a crafted branch name cannot make the menu read as a different worktree than the one a removal acts on. Line separators become a single space rather than disappearing, so multi-line Git output in a destructive confirmation never welds two records into one.
132
133
  - Safe removal invokes argv-based `git worktree move <path> <quarantine>` before validation, moves the real tree to a private tombstone, reserves the registered path with an exclusive non-directory entry, and uses an isolated `git worktree prune --expire now` for metadata only. Failed pre-removal recovery uses the inverse Git move and retains an unsafe quarantine; production runtime never invokes a shell, `rm`, or `rm -rf`.
133
134
  - Prune always runs `git worktree prune --dry-run --verbose` before confirmation, inspects candidates omitted from porcelain, rechecks the exact preview and recovery-risk set after confirmation, and uses Git's default expiry. Remove likewise rechecks worktree identity, filesystem path identity, inventory immediately before deletion, administrative path, and the approved recovery-risk set before mutation.
135
+ - Add resolves the base commit before confirmation — for a new branch from the requested start point, and for an existing branch from `refs/heads/<branch>` so a same-named tag cannot stand in — shows branch, base ref, full base OID, and path in the confirmation, and then re-reads `git worktree list`, the local branch ref, and the base OID under the worktree mutation lock immediately before `git worktree add`. A base that moved, a branch that was created, deleted, or checked out elsewhere, or a path that was claimed while the dialog was open refuses the add instead of creating a worktree from a base the user did not approve; the message names both OIDs so you can re-run Add to approve the new base. The created worktree is verified against the approved branch ref and base OID.
134
136
  - The extension does not expose commit, push, rebase, repair, user-requested move, lock, or unlock worktree actions.
135
137
 
136
138
  Use Git directly when you intentionally need force removal, branch deletion, custom prune expiry, detach/orphan creation, move, repair, lock, or unlock behavior.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-worktree",
3
- "version": "0.49.3",
3
+ "version": "1.2.0",
4
4
  "description": "Pi extension for safe interactive Git worktree management and workspace switching.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/command.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  formatWorktree,
15
15
  listWorktrees,
16
16
  localBranchExists,
17
+ localBranchRef,
17
18
  pathEntryExists,
18
19
  pathIdentity,
19
20
  pathsEqual,
@@ -58,6 +59,15 @@ interface AdministrativeHistoryRisk {
58
59
  oids: string[];
59
60
  }
60
61
 
62
+ /** The base commit the user approved, captured before the confirmation and re-checked after it. */
63
+ interface AddBaseProvenance {
64
+ kind: "create" | "attach";
65
+ /** Human-facing base, e.g. "main" for a new branch or "refs/heads/feature" for an attach. */
66
+ label: string;
67
+ /** Lowercased full OID that `label` resolved to when the preview was built. */
68
+ oid: string;
69
+ }
70
+
61
71
  export function registerWorktreeCommand(
62
72
  pi: ExtensionAPI,
63
73
  settings: WorktreeSettingsRuntime,
@@ -179,9 +189,18 @@ async function addFlow(
179
189
  throw new Error(`Branch ${branch} is already checked out at ${occupied.path}.`);
180
190
  }
181
191
 
182
- let startOid: string | undefined;
183
- let startLabel: string | undefined;
184
- if (!branchExists) {
192
+ // Capture the base commit for BOTH cases. The attach case never had one: argv is
193
+ // ["worktree", "add", <path>, <branch>], so Git resolves the branch at exec time and a
194
+ // concurrent `git branch -f` or rebase silently moved the base out from under the user.
195
+ let provenance: AddBaseProvenance;
196
+ if (branchExists) {
197
+ const ref = localBranchRef(branch);
198
+ provenance = {
199
+ kind: "attach",
200
+ label: ref,
201
+ oid: (await resolveCommit(pi, ctx.cwd, ref, ctx.signal)).toLowerCase(),
202
+ };
203
+ } else {
185
204
  const defaultStart = await symbolicBranch(pi, ctx.cwd, ctx.signal);
186
205
  const requestedStart = await ctx.ui.input(
187
206
  stripTerminalControls(
@@ -192,9 +211,13 @@ async function addFlow(
192
211
  stripTerminalControls(defaultStart ?? "commit-ish"),
193
212
  );
194
213
  if (requestedStart === undefined) return;
195
- startLabel = requestedStart.trim() || defaultStart;
214
+ const startLabel = requestedStart.trim() || defaultStart;
196
215
  if (!startLabel) throw new Error("An explicit start point is required from detached HEAD.");
197
- startOid = await resolveCommit(pi, ctx.cwd, startLabel, ctx.signal);
216
+ provenance = {
217
+ kind: "create",
218
+ label: startLabel,
219
+ oid: (await resolveCommit(pi, ctx.cwd, startLabel, ctx.signal)).toLowerCase(),
220
+ };
198
221
  }
199
222
 
200
223
  const suggestedPath = defaultWorktreePath(main.path, branch, worktreeRoot);
@@ -210,27 +233,95 @@ async function addFlow(
210
233
  throw new Error(`The target path is already registered as a worktree: ${pathCollision.path}.`);
211
234
  }
212
235
 
213
- const summary = branchExists
214
- ? `Attach existing branch ${branch} at ${targetPath}?`
215
- : `Create branch ${branch} from ${startLabel} at ${targetPath}?`;
216
- if (!(await ctx.ui.confirm("Create Git worktree", stripTerminalControls(summary)))) return;
236
+ // Sanitize each line, then join: stripTerminalControls maps a newline to a space, so stripping
237
+ // the joined string would collapse the preview back onto one line.
238
+ const summary = [
239
+ provenance.kind === "attach" ? `Attach existing branch ${branch}.` : `Create branch ${branch}.`,
240
+ `Branch: ${branch}`,
241
+ `Base: ${provenance.label}`,
242
+ `Base commit: ${provenance.oid}`,
243
+ `Path: ${targetPath}`,
244
+ ]
245
+ .map((line) => stripTerminalControls(line))
246
+ .join("\n");
247
+ if (!(await ctx.ui.confirm("Create Git worktree", summary))) return;
248
+
249
+ // Re-check under the mutation lock, mirroring pruneFlow: `records` was captured when the menu
250
+ // opened, so every pre-confirmation check above is stale by the whole menu lifetime. The lock
251
+ // cannot stop an external `git branch -f` — that is what the base-OID check is for — but it does
252
+ // shrink the window against this package's own flows, and it never spans a dialog.
253
+ const created = await withWorktreeMutationLock(
254
+ ctx.cwd,
255
+ async () => {
256
+ assertTargetFilesystemAvailable(targetPath);
217
257
 
218
- assertTargetFilesystemAvailable(targetPath);
219
- await addWorktree(pi, ctx.cwd, { path: targetPath, branch, startOid }, ctx.signal);
220
- let created: WorktreeRecord;
221
- try {
222
- const updated = await listWorktrees(pi, ctx.cwd, ctx.signal);
223
- const verified = updated.find((record) => pathsEqual(record.path, targetPath));
224
- if (!verified || verified.branch !== branch) {
225
- throw new Error("the expected path and branch were not present in Git porcelain output");
226
- }
227
- created = verified;
228
- } catch (error) {
229
- throw new Error(
230
- `Git add completed, so the worktree was retained at ${targetPath}, but verification failed: ${formatError(error)}. Inspect git worktree list before retrying.`,
231
- );
232
- }
233
- safeNotify(ctx, `Created worktree ${targetPath} on branch ${branch}.`, "info");
258
+ const latest = await listWorktrees(pi, ctx.cwd, ctx.signal);
259
+ const occupiedNow = worktreeForBranch(latest, branch);
260
+ if (occupiedNow) {
261
+ throw new Error(`Branch ${branch} was checked out at ${occupiedNow.path} after confirmation; add was refused.`);
262
+ }
263
+ const collisionNow = latest.find((record) => pathsEqual(record.path, targetPath));
264
+ if (collisionNow) {
265
+ throw new Error(`The target path became a registered worktree after confirmation: ${collisionNow.path}.`);
266
+ }
267
+
268
+ const existsNow = await localBranchExists(pi, ctx.cwd, branch, ctx.signal);
269
+ if (existsNow !== branchExists) {
270
+ throw new Error(
271
+ existsNow
272
+ ? `Branch ${branch} was created by another process after confirmation; add was refused.`
273
+ : `Branch ${branch} was deleted after confirmation; add was refused.`,
274
+ );
275
+ }
276
+
277
+ const currentOid = (
278
+ await resolveCommit(
279
+ pi,
280
+ ctx.cwd,
281
+ provenance.kind === "attach" ? localBranchRef(branch) : provenance.label,
282
+ ctx.signal,
283
+ )
284
+ ).toLowerCase();
285
+ if (!sameOid(currentOid, provenance.oid)) {
286
+ throw new Error(
287
+ `${provenance.label} moved from ${provenance.oid} to ${currentOid} after confirmation; add was refused. Run Add again to approve the new base.`,
288
+ );
289
+ }
290
+
291
+ await addWorktree(
292
+ pi,
293
+ ctx.cwd,
294
+ {
295
+ path: targetPath,
296
+ branch,
297
+ startOid: provenance.kind === "create" ? provenance.oid : undefined,
298
+ },
299
+ ctx.signal,
300
+ );
301
+
302
+ try {
303
+ const updated = await listWorktrees(pi, ctx.cwd, ctx.signal);
304
+ const verified = updated.find((record) => pathsEqual(record.path, targetPath));
305
+ // branchRef is stricter than branch: a deleted branch shadowed by a same-named tag produces
306
+ // a detached record whose branchRef is undefined.
307
+ if (
308
+ !verified ||
309
+ verified.branchRef !== localBranchRef(branch) ||
310
+ !verified.head ||
311
+ !sameOid(verified.head, provenance.oid)
312
+ ) {
313
+ throw new Error("the expected path, branch, and base commit were not present in Git porcelain output");
314
+ }
315
+ return verified;
316
+ } catch (error) {
317
+ throw new Error(
318
+ `Git add completed, so the worktree was retained at ${targetPath}, but verification failed: ${formatError(error)}. Inspect git worktree list before retrying.`,
319
+ );
320
+ }
321
+ },
322
+ ctx.signal,
323
+ );
324
+ safeNotify(ctx, `Created worktree ${targetPath} on branch ${branch} at ${provenance.oid}.`, "info");
234
325
 
235
326
  if (
236
327
  await ctx.ui.confirm("Switch Pi workspace?", stripTerminalControls(`Continue this conversation in ${targetPath}?`))
@@ -243,6 +334,10 @@ async function addFlow(
243
334
  }
244
335
  }
245
336
 
337
+ function sameOid(left: string, right: string): boolean {
338
+ return left.toLowerCase() === right.toLowerCase();
339
+ }
340
+
246
341
  function assertTargetFilesystemAvailable(targetPath: string): void {
247
342
  if (pathEntryExists(targetPath)) {
248
343
  throw new Error(`The target path already exists: ${targetPath}.`);
package/src/git.ts CHANGED
@@ -137,8 +137,17 @@ export function parseWorktreePorcelain(output: string): WorktreeRecord[] {
137
137
  return records;
138
138
  }
139
139
 
140
+ /**
141
+ * Fully qualify a local branch. Provenance must be resolved through this ref rather than the bare
142
+ * name: `git rev-parse` prefers `refs/tags/<name>`, so a same-named tag would otherwise yield the
143
+ * OID of an object `git worktree add <path> <branch>` will not check out.
144
+ */
145
+ export function localBranchRef(branch: string): string {
146
+ return `${LOCAL_BRANCH_PREFIX}${branch}`;
147
+ }
148
+
140
149
  export function worktreeForBranch(records: readonly WorktreeRecord[], branch: string): WorktreeRecord | undefined {
141
- const branchRef = `${LOCAL_BRANCH_PREFIX}${branch}`;
150
+ const branchRef = localBranchRef(branch);
142
151
  return records.find((record) => record.branchRef === branchRef);
143
152
  }
144
153
 
@@ -263,12 +272,7 @@ export async function localBranchExists(
263
272
  branch: string,
264
273
  signal?: AbortSignal,
265
274
  ): Promise<boolean> {
266
- const result = await runGitAllowFailure(
267
- pi,
268
- ["show-ref", "--verify", "--quiet", `${LOCAL_BRANCH_PREFIX}${branch}`],
269
- cwd,
270
- signal,
271
- );
275
+ const result = await runGitAllowFailure(pi, ["show-ref", "--verify", "--quiet", localBranchRef(branch)], cwd, signal);
272
276
  if (result.killed) throw killedError(["show-ref", "--verify", "--quiet"]);
273
277
  if (result.code === 0) return true;
274
278
  if (result.code === 1) return false;
@@ -627,7 +631,9 @@ export async function administrativePruneCandidates(
627
631
  candidates.push(candidate);
628
632
  administrativeIdentities.set(candidate, metadataSnapshot(candidate.administrativePath));
629
633
  };
630
- for (const entry of readdirSync(administrativeRoot, { withFileTypes: true })) {
634
+ for (const entry of readdirSync(administrativeRoot, {
635
+ withFileTypes: true,
636
+ })) {
631
637
  const administrativePath = resolve(administrativeRoot, entry.name);
632
638
  if (!entry.isDirectory() || entry.isSymbolicLink()) {
633
639
  throw new GitWorktreeError(`Unexpected Git worktree administrative entry: ${administrativePath}.`);
@@ -948,15 +954,55 @@ export function formatWorktree(record: WorktreeRecord, currentPath?: string): st
948
954
  return stripTerminalControls(`${record.path} [${labels.join(", ") || "unknown"}] ${head}`);
949
955
  }
950
956
 
957
+ /**
958
+ * Layout-preserving sanitizer contract (the same shape pi-statusline implements, duplicated per the
959
+ * package boundary rule; pi-recall implements the prose variant that also collapses whitespace):
960
+ * - line separators become one space, so multi-line Git output stays readable as a single line
961
+ * instead of welding the end of one record onto the start of the next;
962
+ * - every other unsafe code point (C0, DEL/C1, bidi overrides) is dropped with no replacement;
963
+ * - whitespace is neither collapsed nor trimmed.
964
+ * There is no escape-sequence parser here: dropping ESC and the C1 introducers already leaves any
965
+ * residual payload as inert text, and consuming it would let one byte erase the rest of a
966
+ * destructive confirmation prompt.
967
+ */
951
968
  export function stripTerminalControls(value: string): string {
952
969
  return [...value]
953
- .filter((character) => {
954
- const code = character.codePointAt(0) ?? 0;
955
- return code > 0x1f && (code < 0x7f || code > 0x9f);
970
+ .map((character) => {
971
+ const codePoint = character.codePointAt(0) ?? 0;
972
+ if (isLineSeparator(codePoint)) return " ";
973
+ return isUnsafeTerminalCodePoint(codePoint) ? "" : character;
956
974
  })
957
975
  .join("");
958
976
  }
959
977
 
978
+ // Git output is joined with newlines before it reaches a confirmation body. Dropping them outright
979
+ // glued "Removing a: reason" onto "Removing b: reason", changing what the user reads before
980
+ // approving an irreversible prune or removal.
981
+ function isLineSeparator(codePoint: number): boolean {
982
+ return (
983
+ codePoint === 0x09 ||
984
+ codePoint === 0x0a ||
985
+ codePoint === 0x0d ||
986
+ codePoint === 0x85 ||
987
+ codePoint === 0x2028 ||
988
+ codePoint === 0x2029
989
+ );
990
+ }
991
+
992
+ // Bidi controls reorder what the menu shows without changing the path we act on, so a removal
993
+ // prompt could name one worktree while the user reads another.
994
+ function isUnsafeTerminalCodePoint(codePoint: number): boolean {
995
+ return (
996
+ codePoint <= 0x1f ||
997
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
998
+ codePoint === 0x061c ||
999
+ codePoint === 0x200e ||
1000
+ codePoint === 0x200f ||
1001
+ (codePoint >= 0x202a && codePoint <= 0x202e) ||
1002
+ (codePoint >= 0x2066 && codePoint <= 0x2069)
1003
+ );
1004
+ }
1005
+
960
1006
  async function runGit(
961
1007
  pi: Pick<ExtensionAPI, "exec">,
962
1008
  args: string[],
@@ -1089,7 +1135,11 @@ function runGitWithInputAllowFailure(
1089
1135
  }
1090
1136
  return new Promise((resolveResult, reject) => {
1091
1137
  // ExtensionAPI.exec has no stdin channel, so this read-only check uses an argv-only child.
1092
- const child = spawn("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
1138
+ const child = spawn("git", args, {
1139
+ cwd,
1140
+ stdio: ["pipe", "pipe", "pipe"],
1141
+ windowsHide: true,
1142
+ });
1093
1143
  let stdout = "";
1094
1144
  let stderr = "";
1095
1145
  let killed = false;
@@ -1132,7 +1182,12 @@ function runGitWithInputAllowFailure(
1132
1182
  });
1133
1183
  child.once("error", fail);
1134
1184
  child.once("close", (code, closeSignal) => {
1135
- finish({ stdout, stderr, code: code ?? 1, killed: killed || closeSignal !== null });
1185
+ finish({
1186
+ stdout,
1187
+ stderr,
1188
+ code: code ?? 1,
1189
+ killed: killed || closeSignal !== null,
1190
+ });
1136
1191
  });
1137
1192
  signal?.addEventListener("abort", stop, { once: true });
1138
1193
  if (signal?.aborted) stop();