@henryqw/pi-herdr-done 0.4.2 → 0.5.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/README.md CHANGED
@@ -12,20 +12,21 @@ pi install npm:@henryqw/pi-herdr-done
12
12
 
13
13
  | Surface | Type | Purpose |
14
14
  | --- | --- | --- |
15
- | `/done` | command | Remove the current worktree checkout, fast-forward the parent workspace, and close only this session's tab. |
16
- | `/done --force` | command | Same, even when the worktree is dirty or used by other Herdr tabs. |
15
+ | `/done` | command | Remove the current worktree checkout, close its Herdr workspace's tabs, and fast-forward the parent workspace. |
16
+ | `/done --force` | command | Same, even when the worktree is dirty or used by tabs in another workspace. |
17
17
 
18
18
  Both forms wait for Pi to become idle. `/done` asks for confirmation first; `/done --force` skips it because the flag already states intent. Normal removal runs:
19
19
 
20
20
  ```bash
21
21
  git worktree remove .
22
+ herdr tab close <other-tabs-in-$HERDR_WORKSPACE_ID>
22
23
  git -C <parent> pull --ff-only
23
24
  herdr tab close "$HERDR_TAB_ID"
24
25
  ```
25
26
 
26
- The parent pull runs only when this session ran in a linked worktree with a non-bare primary. It fails safely when the parent has diverged. Parent tabs do not block worktree removal or the parent pull. Once removal succeeds, the current tab closes even when the parent pull fails. Concurrent completions serialize on a lock around the parent checkout.
27
+ The parent pull runs only when this session ran in a linked worktree with a non-bare primary. It fails safely when the parent has diverged. Parent tabs do not block worktree removal or the parent pull. Once removal succeeds, every tab in the current Herdr workspace closes even when the parent pull fails. Concurrent completions serialize on a lock around the parent checkout.
27
28
 
28
- Only the current session's tab closes, so sibling tabs in the same workspace survive. When another Herdr tab still uses the current checkout, `/done` refuses and lists the blocking tabs by name; use `/done --force` to remove the checkout regardless. Command requires Pi running inside Herdr with `HERDR_ENV=1` and `HERDR_TAB_ID` set.
29
+ Tabs in the current Herdr workspace close automatically. When a tab from another workspace still uses the current checkout, `/done` refuses and lists it by name; use `/done --force` to remove the checkout regardless. Command requires Pi running inside Herdr with `HERDR_ENV=1`, `HERDR_WORKSPACE_ID`, and `HERDR_TAB_ID` set.
29
30
 
30
31
  Dirty worktrees make `/done` fail. Commit or discard changes, or use `/done --force` to explicitly delete them.
31
32
 
@@ -5,11 +5,10 @@ import { createHerdrClient, withWorktreeLock } from "@henryqw/pi-herdr";
5
5
  type ExecResult = { stdout: string; stderr: string; code: number; killed?: boolean };
6
6
 
7
7
  type SnapshotPane = { tab_id?: unknown; cwd?: unknown };
8
- type TabEntry = { tab_id?: unknown; label?: unknown };
8
+ type TabEntry = { tab_id?: unknown; workspace_id?: unknown; label?: unknown };
9
9
 
10
10
  export default function herdrDoneExtension(pi: ExtensionAPI): void {
11
- const herdr = createHerdrClient<{ cwd: string }>((command, args, options) =>
12
- pi.exec(command, [...args], options));
11
+ const herdr = createHerdrClient<{ cwd: string }>(pi.exec.bind(pi));
13
12
 
14
13
  const execOrThrow = async (command: string, args: string[], cwd: string): Promise<string> => {
15
14
  const result = await pi.exec(command, args, { cwd }) as unknown as ExecResult;
@@ -20,7 +19,7 @@ export default function herdrDoneExtension(pi: ExtensionAPI): void {
20
19
  };
21
20
 
22
21
  pi.registerCommand("done", {
23
- description: "Remove the current Herdr worktree, then fast-forward its parent workspace",
22
+ description: "Remove the current Herdr worktree and close its workspace tabs",
24
23
  handler: async (args, ctx) => {
25
24
  const option = args.trim();
26
25
  if (option && option !== "--force") throw new Error("Usage: /done [--force]");
@@ -29,9 +28,11 @@ export default function herdrDoneExtension(pi: ExtensionAPI): void {
29
28
  }
30
29
  const tabId = process.env.HERDR_TAB_ID?.trim();
31
30
  if (!tabId) throw new Error("HERDR_TAB_ID is missing.");
31
+ const workspaceId = process.env.HERDR_WORKSPACE_ID?.trim();
32
+ if (!workspaceId) throw new Error("HERDR_WORKSPACE_ID is missing.");
32
33
 
33
34
  if (option !== "--force") {
34
- const confirmed = await ctx.ui.confirm("Done", "Close and remove the current Herdr worktree?");
35
+ const confirmed = await ctx.ui.confirm("Done", "Close its Herdr tabs and remove the current worktree?");
35
36
  if (!confirmed) return;
36
37
  }
37
38
 
@@ -46,28 +47,40 @@ export default function herdrDoneExtension(pi: ExtensionAPI): void {
46
47
  const parentIsBare = mainCheckout !== checkout &&
47
48
  worktreeFields.slice(1, worktreeFields.indexOf("")).includes("bare");
48
49
 
50
+ let siblingTabIds: string[] = [];
49
51
  await withWorktreeLock(checkout, async () => {
50
52
  // With --force, skip the dependents check entirely and let git remove the checkout.
53
+ let dependentIds: string[] = [];
51
54
  if (option !== "--force") {
52
55
  const snapshot = await herdr.json(["api", "snapshot"], { cwd: ctx.cwd });
53
56
  const panes = (snapshot.result as { snapshot?: { panes?: SnapshotPane[] } } | undefined)?.snapshot?.panes;
54
57
  if (!Array.isArray(panes)) throw new Error("herdr api snapshot returned no panes.");
55
- const dependentIds = [...new Set(panes
58
+ dependentIds = [...new Set(panes
56
59
  .filter((pane): pane is SnapshotPane & { tab_id: string; cwd: string } =>
57
60
  typeof pane.tab_id === "string" && pane.tab_id !== tabId && typeof pane.cwd === "string" &&
58
61
  (pane.cwd === checkout || pane.cwd.startsWith(`${checkout}/`)))
59
62
  .map((pane) => pane.tab_id))];
60
- if (dependentIds.length > 0) {
61
- const listing = await herdr.json(["tab", "list"], { cwd: ctx.cwd });
62
- const tabs = (listing.result as { tabs?: TabEntry[] } | undefined)?.tabs;
63
- const labels = new Map(
64
- (Array.isArray(tabs) ? tabs : [])
65
- .filter((tab): tab is TabEntry & { tab_id: string; label: string } =>
66
- typeof tab.tab_id === "string" && typeof tab.label === "string")
67
- .map((tab) => [tab.tab_id, tab.label]));
68
- const names = [...new Set(dependentIds.map((id) => labels.get(id) ?? id))];
69
- throw new Error(`Worktree still used by Herdr tabs ${names.join(", ")}; close them first.`);
70
- }
63
+ }
64
+ const listing = await herdr.json(["tab", "list"], { cwd: ctx.cwd });
65
+ const tabs = (listing.result as { tabs?: TabEntry[] } | undefined)?.tabs;
66
+ if (!Array.isArray(tabs)) throw new Error("herdr tab list returned no tabs.");
67
+ const tabsById = new Map(tabs
68
+ .filter((tab): tab is TabEntry & { tab_id: string } => typeof tab.tab_id === "string")
69
+ .map((tab) => [tab.tab_id, tab]));
70
+ if (tabsById.get(tabId)?.workspace_id !== workspaceId) {
71
+ throw new Error(`Herdr tab ${tabId} does not belong to workspace ${workspaceId}.`);
72
+ }
73
+ siblingTabIds = tabs
74
+ .filter((tab): tab is TabEntry & { tab_id: string } =>
75
+ typeof tab.tab_id === "string" && tab.tab_id !== tabId && tab.workspace_id === workspaceId)
76
+ .map((tab) => tab.tab_id);
77
+ const blockers = dependentIds.filter((id) => tabsById.get(id)?.workspace_id !== workspaceId);
78
+ if (blockers.length > 0) {
79
+ const names = blockers.map((id) => {
80
+ const label = tabsById.get(id)?.label;
81
+ return typeof label === "string" ? label : id;
82
+ });
83
+ throw new Error(`Worktree still used by Herdr tabs ${names.join(", ")}; close them first.`);
71
84
  }
72
85
 
73
86
  await execOrThrow("git", [
@@ -75,13 +88,14 @@ export default function herdrDoneExtension(pi: ExtensionAPI): void {
75
88
  ], ctx.cwd);
76
89
  });
77
90
  try {
91
+ await Promise.all(siblingTabIds.map((id) => herdr.run(["tab", "close", id], { cwd: tmpdir() })));
78
92
  if (!parentIsBare && mainCheckout !== checkout) {
79
93
  // Serialize concurrent completions pulling the same parent checkout.
80
94
  // Run outside the removed checkout because its directory no longer exists.
81
95
  await withWorktreeLock(mainCheckout, () => execOrThrow("git", ["pull", "--ff-only"], mainCheckout));
82
96
  }
83
97
  } finally {
84
- // Close only this session's tab once its checkout is removed, even if the parent pull fails.
98
+ // Close this session's tab last, even if sibling cleanup or the parent pull fails.
85
99
  await herdr.run(["tab", "close", tabId], { cwd: tmpdir() });
86
100
  }
87
101
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-herdr-done",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Close and remove the current Herdr worktree from Pi.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -27,7 +27,7 @@
27
27
  "@earendil-works/pi-coding-agent": "^0.84.2"
28
28
  },
29
29
  "dependencies": {
30
- "@henryqw/pi-herdr": "^0.2.0"
30
+ "@henryqw/pi-herdr": "^0.3.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",