@signalridge/pi-worktree 1.2.1 → 1.4.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,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.0
4
+ ### Minor Changes
5
+
6
+ - 07350d4: Standardize extension-owned popup surfaces with an idempotent Pi-style border adapter. Native Pi dialogs retain their built-in framing and RPC behavior; custom menus and overlays gain consistent border rules.
7
+
8
+ ### Patch Changes
9
+
10
+ - Updated dependencies [07350d4]
11
+ - @signalridge/pi-ui@1.3.0
12
+
13
+ ## 1.3.1
14
+ ### Patch Changes
15
+
16
+ - f714ea0: Publish the package versions already prepared by the previous release transition after its first publish attempt was blocked before npm publication.
17
+
18
+ ## 1.3.0
19
+ ### Minor Changes
20
+
21
+ - b6cf242: New `/worktree → Browse worktree status`: a readable view of what has actually changed in a worktree, grouped as conflicted / staged / unstaged / untracked.
22
+
23
+ It reads `git status --porcelain=v2`, not the v1 the existing safety check uses. That check is unchanged and stays on v1, correctly — it only needs to know whether a worktree is dirty, and v1's flat lines answer that in the fewest moving parts. This is the other job: a person reads the result before deciding whether to remove, switch away from, or commit in a worktree, and "3 changes" does not settle any of those. v2 is what makes the listing unambiguous — staged and unstaged as separate values rather than one overloaded pair of letters, a rename's similarity score *and* its original path, unmerged entries flagged as conflicts rather than rendered as a misleading staged/unstaged pair, and submodules distinguished from modified files.
24
+
25
+ Read with `-z`, so a path containing a newline or a quote is shown exactly as git wrote it. Parsing is total: an unrecognized record is skipped rather than throwing, so a future git version adding one costs that row and not the screen. `git.ts` keeps its raw-output boundary and stays free of intra-package imports — a test loads it standalone under Node's strip-only TypeScript, which cannot resolve a sibling `./x.js` to `x.ts` — so the parser lives beside it and is applied at the consumer.
26
+
27
+ ### Patch Changes
28
+
29
+ - b6cf242: Peer dependency ranges now name the versions actually validated against, so an untested host combination fails at install time instead of silently at runtime: `@earendil-works/pi-coding-agent`, `pi-ai`, `pi-tui`, and `pi-agent-core` move from `"*"` to `^0.84.0`, and `typebox` from `"*"` to `^1.3.11`.
30
+
31
+ Shared dependencies now carry ONE declared range across every package that uses them, and `bun run check:shared-deps` keeps it that way.
32
+
33
+ `@narumitw/pi-tui-kit` was declared at three disjoint floors — `^0.54.0`, `^0.51.0`, and `^0.49.1` across nine packages — and the lockfile duly resolved three copies (0.54.0, 0.51.0, 0.49.3) installed side by side. For a shared rendering surface drawing into one terminal inside one host process, that means a theme rendering one way in one extension and another way in the next, with nothing failing at install to say so. All nine now declare `^0.54.0` and the install resolves a single copy. `@sinclair/typebox` likewise converges on `^0.34.50`.
34
+
35
+ The new check covers `dependencies` and `peerDependencies` and compares range strings rather than their semantics: two ranges that merely overlap are still a finding, because the goal is one intentional answer per dependency rather than an accidental intersection. `devDependencies` are deliberately out of scope — a build tool is not a shared surface, and `pi-subagents` intentionally carries its own toolchain. `docs/package-boundaries.md` documents the Kit as a shared surface for the first time.
36
+
3
37
  ## 1.2.1
4
38
  ### Patch Changes
5
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-worktree",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Pi extension for safe interactive Git worktree management and workspace switching.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  "lint": "biome check ."
36
36
  },
37
37
  "peerDependencies": {
38
- "@earendil-works/pi-coding-agent": "*"
38
+ "@earendil-works/pi-coding-agent": "^0.84.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@biomejs/biome": "2.5.7",
@@ -59,7 +59,8 @@
59
59
  "access": "public"
60
60
  },
61
61
  "dependencies": {
62
- "@narumitw/pi-tui-kit": "^0.49.1",
62
+ "@narumitw/pi-tui-kit": "^0.54.0",
63
+ "@signalridge/pi-ui": "^1.3.0",
63
64
  "proper-lockfile": "^4.1.2"
64
65
  }
65
66
  }
package/src/command.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, lstatSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
4
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
5
+ import { withBorderedCustomUi } from "@signalridge/pi-ui";
5
6
  import {
6
7
  type AdministrativePruneCandidate,
7
8
  addWorktree,
@@ -31,20 +32,24 @@ import {
31
32
  worktreeAdministrativeDirectory,
32
33
  worktreeForBranch,
33
34
  worktreeInventory,
35
+ worktreeStatusOutput,
34
36
  } from "./git.js";
35
37
  import { removeWorktreeSafely } from "./safe-remove.js";
36
38
  import { switchToWorktree } from "./session.js";
37
39
  import type { WorktreeSettingsRuntime } from "./settings.js";
40
+ import { formatStatusEntry, groupStatusEntries, parsePorcelainV2, type StatusEntry } from "./status.js";
38
41
 
39
42
  const ACTION_ADD = "Add worktree";
40
43
  const ACTION_SWITCH = "Switch worktree";
41
44
  const ACTION_REMOVE = "Remove worktree";
45
+ const ACTION_STATUS = "Browse worktree status";
42
46
  const ACTION_PRUNE = "Prune stale metadata";
43
47
  const ACTION_CONFIGURE_ROOT = "Configure worktree root";
44
48
  const ACTIONS = {
45
49
  add: ACTION_ADD,
46
50
  switch: ACTION_SWITCH,
47
51
  remove: ACTION_REMOVE,
52
+ status: ACTION_STATUS,
48
53
  prune: ACTION_PRUNE,
49
54
  configure: ACTION_CONFIGURE_ROOT,
50
55
  } as const;
@@ -126,11 +131,12 @@ export function registerWorktreeCommand(
126
131
  add: async () => runFlow(() => addFlow(pi, ctx, records, root.effectiveRoot)),
127
132
  switch: async ({ signal }) => runFlow(() => switchFlow(pi, ctx, records, currentPath, signal)),
128
133
  remove: async ({ signal }) => runFlow(() => removeFlow(pi, ctx, records, currentPath, signal)),
134
+ status: async ({ signal }) => runFlow(() => statusFlow(pi, ctx, records, currentPath, signal)),
129
135
  prune: async () => runFlow(() => pruneFlow(pi, ctx, records)),
130
136
  configure: async () => runFlow(() => configureRootFlow(ctx, settings)),
131
137
  },
132
138
  });
133
- await runMenu(ctx, menu, {
139
+ await runMenu(withBorderedCustomUi(ctx), menu, {
134
140
  getState: () => undefined,
135
141
  signal: owner.signal,
136
142
  isCurrent: owner.isCurrent,
@@ -514,6 +520,49 @@ async function removeFlow(
514
520
  safeNotify(ctx, `Removed worktree ${selected.path}. Its branch was preserved.`, "info");
515
521
  }
516
522
 
523
+ /**
524
+ * Show one worktree's status, grouped the way a commit is assembled.
525
+ *
526
+ * The point of a browser rather than a count is that "3 changes" is not enough
527
+ * to decide anything: removing a worktree, switching away from one, or
528
+ * committing in it are all decisions about WHICH changes exist. Conflicts lead
529
+ * because nothing else can proceed past them.
530
+ */
531
+ async function statusFlow(
532
+ pi: ExtensionAPI,
533
+ ctx: ExtensionCommandContext,
534
+ records: readonly WorktreeRecord[],
535
+ currentPath: string,
536
+ signal?: AbortSignal,
537
+ ): Promise<void> {
538
+ const target =
539
+ records.length === 1
540
+ ? records[0]
541
+ : await selectWorktree(ctx, "Browse status of which worktree?", records, currentPath, signal);
542
+ if (!target) return;
543
+
544
+ const entries = parsePorcelainV2(await worktreeStatusOutput(pi, target.path, signal), "\0");
545
+ if (entries.length === 0) {
546
+ safeNotify(ctx, `${target.path} is clean.`, "info");
547
+ return;
548
+ }
549
+
550
+ const groups = groupStatusEntries(entries);
551
+ const lines: string[] = [];
552
+ const section = (label: string, group: readonly StatusEntry[]) => {
553
+ if (group.length === 0) return;
554
+ lines.push(`${label} (${group.length}):`);
555
+ for (const entry of group) lines.push(` ${formatStatusEntry(entry)}`);
556
+ lines.push("");
557
+ };
558
+ section("Conflicted", groups.conflicted);
559
+ section("Staged", groups.staged);
560
+ section("Unstaged", groups.unstaged);
561
+ section("Untracked", groups.untracked);
562
+
563
+ safeNotify(ctx, [`${target.path}`, "", ...lines].join("\n").trimEnd(), "info");
564
+ }
565
+
517
566
  async function pruneFlow(
518
567
  pi: ExtensionAPI,
519
568
  ctx: ExtensionCommandContext,
@@ -784,9 +833,11 @@ async function selectWorktree(
784
833
  worktrees: () => ({
785
834
  kind: "choice",
786
835
  title,
836
+ enableSearch: true,
787
837
  items: records.map((record, index) => ({
788
838
  id: record.path,
789
839
  label: `${index + 1}. ${formatWorktree(record, currentPath)}`,
840
+ searchText: `${record.branch ?? ""} ${record.path} ${formatWorktree(record, currentPath)}`,
790
841
  })),
791
842
  action: "choose",
792
843
  hint: "close",
@@ -799,7 +850,7 @@ async function selectWorktree(
799
850
  },
800
851
  },
801
852
  });
802
- await runMenu(ctx, menu, {
853
+ await runMenu(withBorderedCustomUi(ctx), menu, {
803
854
  getState: () => undefined,
804
855
  signal,
805
856
  isCurrent: () => !signal?.aborted,
package/src/git.ts CHANGED
@@ -572,6 +572,32 @@ export async function worktreeInventory(
572
572
  ];
573
573
  }
574
574
 
575
+ /**
576
+ * A worktree's status as raw porcelain-v2 output, for the status browser.
577
+ *
578
+ * Separate from `worktreeInventory` on purpose: that reads porcelain v1 for the
579
+ * safety check, where a flat "is anything dirty" answer in the fewest moving
580
+ * parts is exactly right. This reads v2 because a person is going to READ the
581
+ * result, and v2 is the version that distinguishes staged from unstaged,
582
+ * carries a rename's original path and score, and names submodules. Ignored
583
+ * files are excluded: the safety check wants them, a reader does not.
584
+ */
585
+ export async function worktreeStatusOutput(
586
+ pi: Pick<ExtensionAPI, "exec">,
587
+ path: string,
588
+ signal?: AbortSignal,
589
+ ): Promise<string> {
590
+ // Raw output, parsed by the caller: this module deliberately has no
591
+ // intra-package imports (a test loads it standalone under Node's strip-only
592
+ // TypeScript, which cannot resolve a sibling `./x.js` to `x.ts`), so the
593
+ // parser lives in `status.ts` and is applied where the result is consumed.
594
+ //
595
+ // `-z` so a path containing a newline or a quote is read exactly as git wrote
596
+ // it, rather than through the quoting v1 output would need.
597
+ const result = await runGit(pi, ["status", "--porcelain=v2", "--untracked-files=all", "-z"], path, signal);
598
+ return result.stdout;
599
+ }
600
+
575
601
  export async function worktreeAdministrativeDirectory(
576
602
  pi: Pick<ExtensionAPI, "exec">,
577
603
  cwd: string,
package/src/status.ts ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * status.ts — `git status --porcelain=v2`, parsed into something browsable.
3
+ *
4
+ * The safety check in `git.ts` already reads porcelain **v1**, and correctly:
5
+ * it only needs to know whether a worktree is dirty, and v1's flat lines answer
6
+ * that in the fewest moving parts. This is the other job — showing a person
7
+ * WHAT changed before they act on a worktree — and v1 is a poor source for it.
8
+ *
9
+ * v1 gives two status characters and a path. v2 gives the fields that make a
10
+ * listing readable and unambiguous:
11
+ *
12
+ * - staged and unstaged state as separate, explicit values, so "modified in
13
+ * both" is distinguishable from either alone;
14
+ * - renames and copies with their similarity score AND their original path,
15
+ * which v1 only hints at through an arrow inside the path field;
16
+ * - unmerged entries with their conflict stages, rather than one of v1's
17
+ * several overloaded two-letter codes;
18
+ * - a submodule field, so a dirty submodule is not silently a modified file.
19
+ *
20
+ * Parsing is deliberately total: an unrecognized line is skipped rather than
21
+ * throwing. This feeds a viewer, and a future git version adding a record type
22
+ * should cost that one row, not the whole screen.
23
+ */
24
+
25
+ /** What happened to a path on one side (index or worktree). */
26
+ export type StatusChange = "unmodified" | "modified" | "added" | "deleted" | "renamed" | "copied" | "type-changed";
27
+
28
+ export interface StatusEntry {
29
+ path: string;
30
+ /** Staged state — what `git commit` would record. */
31
+ staged: StatusChange;
32
+ /** Unstaged state — what is different in the working tree. */
33
+ unstaged: StatusChange;
34
+ /** Present for renames and copies: where the content came from. */
35
+ originalPath?: string;
36
+ /** Rename/copy similarity, 0–100. */
37
+ similarity?: number;
38
+ /** True when the entry is a submodule rather than a file. */
39
+ submodule?: boolean;
40
+ /** Set on conflicts; the entry is neither staged nor unstaged but both. */
41
+ unmerged?: boolean;
42
+ untracked?: boolean;
43
+ ignored?: boolean;
44
+ }
45
+
46
+ /** Single-letter XY codes shared by the ordinary and rename/copy records. */
47
+ const CHANGES: Readonly<Record<string, StatusChange>> = {
48
+ ".": "unmodified",
49
+ M: "modified",
50
+ A: "added",
51
+ D: "deleted",
52
+ R: "renamed",
53
+ C: "copied",
54
+ T: "type-changed",
55
+ // Unmerged records reuse the same field for their conflict sides.
56
+ U: "modified",
57
+ };
58
+
59
+ function toChange(code: string | undefined): StatusChange {
60
+ return (code && CHANGES[code]) || "unmodified";
61
+ }
62
+
63
+ /**
64
+ * A submodule field is `N...` when the entry is not one, and `S` followed by
65
+ * three flags when it is. Only the distinction matters here.
66
+ */
67
+ function isSubmodule(field: string | undefined): boolean {
68
+ return field?.startsWith("S") === true;
69
+ }
70
+
71
+ /**
72
+ * Parse porcelain v2 output.
73
+ *
74
+ * `-z` output is NUL-separated and puts a rename's original path in its own
75
+ * record, so both separators are accepted: pass NUL-separated text and the
76
+ * paired paths are read correctly, or newline-separated text and the tab form
77
+ * is used instead. Callers that do not need exact paths under unusual filenames
78
+ * can use either; a viewer should prefer `-z`.
79
+ */
80
+ export function parsePorcelainV2(output: string, separator: "\0" | "\n" = "\n"): StatusEntry[] {
81
+ const fields = output.split(separator);
82
+ const entries: StatusEntry[] = [];
83
+
84
+ for (let index = 0; index < fields.length; index++) {
85
+ const line = fields[index];
86
+ if (!line) continue;
87
+
88
+ // `? <path>` — untracked. Neither side has a change to describe.
89
+ if (line.startsWith("? ")) {
90
+ entries.push({ path: line.slice(2), staged: "unmodified", unstaged: "unmodified", untracked: true });
91
+ continue;
92
+ }
93
+ // `! <path>` — ignored. Only present when the caller asked for them.
94
+ if (line.startsWith("! ")) {
95
+ entries.push({ path: line.slice(2), staged: "unmodified", unstaged: "unmodified", ignored: true });
96
+ continue;
97
+ }
98
+
99
+ // `1 XY sub mH mI mW hH hI <path>` — an ordinary change.
100
+ if (line.startsWith("1 ")) {
101
+ const parts = line.split(" ");
102
+ const xy = parts[1] ?? "..";
103
+ // The path is the rest of the line: it may itself contain spaces.
104
+ const path = parts.slice(8).join(" ");
105
+ if (!path) continue;
106
+ entries.push({
107
+ path,
108
+ staged: toChange(xy[0]),
109
+ unstaged: toChange(xy[1]),
110
+ ...(isSubmodule(parts[2]) ? { submodule: true } : {}),
111
+ });
112
+ continue;
113
+ }
114
+
115
+ // `2 XY sub mH mI mW hH hI Xscore <path>` then the ORIGINAL path — a rename
116
+ // or copy. Under `-z` the original is the next NUL-separated field; under
117
+ // newlines it is tab-separated on the same line.
118
+ if (line.startsWith("2 ")) {
119
+ const parts = line.split(" ");
120
+ const xy = parts[1] ?? "..";
121
+ const score = parts[8] ?? "";
122
+ const rest = parts.slice(9).join(" ");
123
+ if (!rest) continue;
124
+ let path = rest;
125
+ let originalPath: string | undefined;
126
+ if (separator === "\0") {
127
+ originalPath = fields[index + 1] || undefined;
128
+ index += 1;
129
+ } else {
130
+ const tab = rest.indexOf("\t");
131
+ if (tab >= 0) {
132
+ path = rest.slice(0, tab);
133
+ originalPath = rest.slice(tab + 1) || undefined;
134
+ }
135
+ }
136
+ const similarity = Number.parseInt(score.slice(1), 10);
137
+ entries.push({
138
+ path,
139
+ staged: toChange(xy[0]),
140
+ unstaged: toChange(xy[1]),
141
+ ...(originalPath ? { originalPath } : {}),
142
+ ...(Number.isFinite(similarity) ? { similarity } : {}),
143
+ ...(isSubmodule(parts[2]) ? { submodule: true } : {}),
144
+ });
145
+ continue;
146
+ }
147
+
148
+ // `u XY sub m1 m2 m3 mW h1 h2 h3 <path>` — unmerged. XY here is the pair of
149
+ // conflict sides rather than staged/unstaged, which is why the entry is
150
+ // flagged: rendering it as "staged X, unstaged Y" would be a lie.
151
+ if (line.startsWith("u ")) {
152
+ const parts = line.split(" ");
153
+ const xy = parts[1] ?? "..";
154
+ const path = parts.slice(10).join(" ");
155
+ if (!path) continue;
156
+ entries.push({
157
+ path,
158
+ staged: toChange(xy[0]),
159
+ unstaged: toChange(xy[1]),
160
+ unmerged: true,
161
+ ...(isSubmodule(parts[2]) ? { submodule: true } : {}),
162
+ });
163
+ }
164
+ // `# ` header lines and anything unrecognized are skipped: this feeds a
165
+ // viewer, and a new record type should cost one row, not the screen.
166
+ }
167
+
168
+ return entries;
169
+ }
170
+
171
+ /** Compact one-line label, e.g. `MM src/a.ts` or `R95 new.ts ← old.ts`. */
172
+ export function formatStatusEntry(entry: StatusEntry): string {
173
+ if (entry.untracked) return `?? ${entry.path}`;
174
+ if (entry.ignored) return `!! ${entry.path}`;
175
+ const code = `${letterFor(entry.staged)}${letterFor(entry.unstaged)}`;
176
+ const marks = [
177
+ entry.unmerged ? "conflict" : undefined,
178
+ entry.submodule ? "submodule" : undefined,
179
+ entry.similarity !== undefined ? `${entry.similarity}%` : undefined,
180
+ ].filter(Boolean);
181
+ const origin = entry.originalPath ? ` ← ${entry.originalPath}` : "";
182
+ const suffix = marks.length > 0 ? ` (${marks.join(", ")})` : "";
183
+ return `${code} ${entry.path}${origin}${suffix}`;
184
+ }
185
+
186
+ function letterFor(change: StatusChange): string {
187
+ switch (change) {
188
+ case "modified":
189
+ return "M";
190
+ case "added":
191
+ return "A";
192
+ case "deleted":
193
+ return "D";
194
+ case "renamed":
195
+ return "R";
196
+ case "copied":
197
+ return "C";
198
+ case "type-changed":
199
+ return "T";
200
+ default:
201
+ return ".";
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Group entries the way a reader thinks about them, in the order a commit is
207
+ * assembled: conflicts first because nothing else can proceed past them, then
208
+ * what is staged, then what is not, then what is not tracked at all.
209
+ */
210
+ export function groupStatusEntries(entries: readonly StatusEntry[]): {
211
+ conflicted: StatusEntry[];
212
+ staged: StatusEntry[];
213
+ unstaged: StatusEntry[];
214
+ untracked: StatusEntry[];
215
+ ignored: StatusEntry[];
216
+ } {
217
+ const groups = {
218
+ conflicted: [] as StatusEntry[],
219
+ staged: [] as StatusEntry[],
220
+ unstaged: [] as StatusEntry[],
221
+ untracked: [] as StatusEntry[],
222
+ ignored: [] as StatusEntry[],
223
+ };
224
+ for (const entry of entries) {
225
+ if (entry.unmerged) groups.conflicted.push(entry);
226
+ else if (entry.untracked) groups.untracked.push(entry);
227
+ else if (entry.ignored) groups.ignored.push(entry);
228
+ else {
229
+ // A path can be in both: staged edits plus further unstaged ones. It is
230
+ // listed under each, because "have I staged everything?" and "what is
231
+ // still uncommitted?" are different questions with different answers.
232
+ if (entry.staged !== "unmodified") groups.staged.push(entry);
233
+ if (entry.unstaged !== "unmodified") groups.unstaged.push(entry);
234
+ }
235
+ }
236
+ return groups;
237
+ }