Skip to content

Commit 97764bf

Browse files
authored
fix(pi-herdr-rename): rename workspace on command (#175)
1 parent ed79f7c commit 97764bf

6 files changed

Lines changed: 166 additions & 66 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/pi-herdr-rename/CONTEXT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Git-safe branch combining task type with display-title words: `Update task logic
1313
_Avoid_: display title, raw generated branch, arbitrary Git mutation
1414

1515
**Generated worktree label**:
16-
Herdr linked-worktree label matching its `worktree-<adjective>-<noun>-<hex>` default. It is replaced with current display title; custom workspace labels remain unchanged.
16+
Herdr linked-worktree label matching its `worktree-<adjective>-<noun>-<hex>` default. It is replaced automatically with current display title; an explicit rename may also replace a custom workspace label.
1717
_Avoid_: semantic branch, custom workspace name
1818

1919
**Sole-pane tab**:

packages/pi-herdr-rename/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ Shared [`pi-task-models` config](../pi-task-models#config) at `~/.pi/agent/confi
3434

3535
Display titles are natural task phrases, preferably three or four words and always at most four words and 20 characters. Model classification stays internal: `refactor: update task logic` displays as `Update task logic` and maps to Git branch `refactor/update-task-logic`.
3636

37-
In a linked worktree, a detached checkout or Herdr `worktree/...` branch is renamed; an existing non-generated branch stays. A generated workspace label such as `worktree-brave-meadow-4aa8` becomes display title; custom workspace names stay. Enclosing Herdr tab updates only when this pane is tab's only pane. Outside Herdr, only Pi session name changes.
37+
In a linked worktree, a detached checkout or Herdr `worktree/...` branch is renamed; an existing non-generated branch stays. A generated workspace label such as `worktree-brave-meadow-4aa8` becomes the display title automatically; `/rename` also replaces a custom workspace name. Enclosing Herdr tab updates only when this pane is tab's only pane. Outside Herdr, only Pi session name changes.
3838

39-
Tries assigned profile primary, then fallback, while honoring configured thinking level. Never substitutes current session model. No viable route leaves titles unchanged. Resuming a session created by this version reapplies saved display title and semantic branch without another model request. Older titles receive no migration.
39+
Tries assigned profile primary, then fallback, while honoring configured thinking level. Never substitutes current session model. No viable route leaves titles unchanged. Resuming a session created by this version reapplies saved display title and semantic branch without another model request. Herdr and Git synchronization failures appear as warnings; cancellation by a newer rename remains silent. Older titles receive no migration.

packages/pi-herdr-rename/extensions/rename.ts

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
type ExtensionAPI,
44
type ExtensionContext,
55
} from "@earendil-works/pi-coding-agent";
6-
import { createHerdrClient } from "@henryqw/pi-herdr";
6+
import { createHerdrClient, withWorktreeLock } from "@henryqw/pi-herdr";
77
import {
88
readTaskModelsConfig,
99
resolveConfiguredTaskRoutes,
@@ -42,15 +42,22 @@ function configuredRenameRoutes(ctx: ExtensionContext): ResolvedTaskRoute[] {
4242
}
4343
}
4444

45+
function validSubject(subject: string): boolean {
46+
return /^[a-z0-9]+(?: [a-z0-9]+)*$/.test(subject)
47+
&& subject.length <= DISPLAY_MAX_CHARS
48+
&& subject.split(" ").length <= DISPLAY_MAX_WORDS;
49+
}
50+
51+
function isDisplayTitle(value: unknown): value is string {
52+
if (typeof value !== "string" || !value) return false;
53+
const subject = value[0].toLowerCase() + value.slice(1);
54+
return validSubject(subject) && value === subject[0].toUpperCase() + subject.slice(1);
55+
}
56+
4557
function parseGeneratedTitle(title: string): GeneratedTitle | undefined {
46-
const match = /^([a-z][a-z0-9-]*): ([a-z0-9]+(?: [a-z0-9]+)*)$/.exec(title);
47-
if (!match) return undefined;
58+
const match = /^([a-z][a-z0-9-]*): (.+)$/.exec(title);
59+
if (!match || match[1].length > SEMANTIC_TYPE_MAX_CHARS || !validSubject(match[2])) return undefined;
4860
const subject = match[2];
49-
if (
50-
match[1].length > SEMANTIC_TYPE_MAX_CHARS ||
51-
subject.length > DISPLAY_MAX_CHARS ||
52-
subject.split(" ").length > DISPLAY_MAX_WORDS
53-
) return undefined;
5461
return {
5562
display: subject[0].toUpperCase() + subject.slice(1),
5663
branch: `${match[1]}/${subject.replaceAll(" ", "-")}`,
@@ -63,7 +70,7 @@ function savedTitle(ctx: ExtensionContext): GeneratedTitle | undefined {
6370
.find((candidate) => candidate.type === "custom" && candidate.customType === TITLE_STATE_TYPE);
6471
if (entry?.type !== "custom" || !entry.data || typeof entry.data !== "object" || Array.isArray(entry.data)) return undefined;
6572
const { display, branch } = entry.data as { display?: unknown; branch?: unknown };
66-
return typeof display === "string" && typeof branch === "string" && SEMANTIC_BRANCH.test(branch)
73+
return isDisplayTitle(display) && typeof branch === "string" && SEMANTIC_BRANCH.test(branch)
6774
? { display, branch }
6875
: undefined;
6976
}
@@ -216,6 +223,7 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
216223
displayTitle: string,
217224
branchCandidate: string,
218225
previousDisplayTitle: string | undefined,
226+
forceWorkspaceRename: boolean,
219227
request: number,
220228
controller: AbortController,
221229
): Promise<void> => {
@@ -247,6 +255,16 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
247255
const workspaceName = workspace?.label;
248256
if (typeof workspaceName !== "string") throw new Error("Herdr workspace response omitted label.");
249257
const worktree = workspace?.worktree;
258+
if (
259+
workspaceName !== displayTitle &&
260+
(forceWorkspaceRename ||
261+
(worktree?.is_linked_worktree === true &&
262+
(HERDR_DEFAULT_WORKTREE_NAME.test(workspaceName) || workspaceName === previousDisplayTitle))) &&
263+
isCurrent(request, controller)
264+
) {
265+
await herdr.run(["workspace", "rename", workspaceId, displayTitle], { signal: controller.signal });
266+
}
267+
250268
const checkoutPath = worktree?.checkout_path;
251269
if (worktree?.is_linked_worktree !== true || typeof checkoutPath !== "string" || !checkoutPath) return;
252270

@@ -258,22 +276,18 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
258276
return result.stdout.trim();
259277
};
260278

261-
const branch = await runGit(["branch", "--show-current"]);
262-
if (!branch || branch.startsWith("worktree/")) {
279+
await withWorktreeLock(checkoutPath, async () => {
263280
if (!isCurrent(request, controller)) return;
264-
const branches = (await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"]))
265-
.split("\n")
266-
.filter(Boolean);
267-
const semanticBranch = availableBranch(branchCandidate, branches);
268-
await runGit(branch ? ["branch", "-m", semanticBranch] : ["switch", "-c", semanticBranch]);
269-
}
270-
if (
271-
workspaceName !== displayTitle &&
272-
(HERDR_DEFAULT_WORKTREE_NAME.test(workspaceName) || workspaceName === previousDisplayTitle) &&
273-
isCurrent(request, controller)
274-
) {
275-
await herdr.run(["workspace", "rename", workspaceId, displayTitle], { signal: controller.signal });
276-
}
281+
const branch = await runGit(["branch", "--show-current"]);
282+
if (!branch || branch.startsWith("worktree/")) {
283+
if (!isCurrent(request, controller)) return;
284+
const branches = (await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"]))
285+
.split("\n")
286+
.filter(Boolean);
287+
const semanticBranch = availableBranch(branchCandidate, branches);
288+
await runGit(branch ? ["branch", "-m", semanticBranch] : ["switch", "-c", semanticBranch]);
289+
}
290+
});
277291
};
278292

279293
const begin = () => {
@@ -300,10 +314,10 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
300314
const previousDisplayTitle = saved && pi.getSessionName() === saved.display ? saved.display : undefined;
301315
pi.setSessionName(title.display);
302316
pi.appendEntry(TITLE_STATE_TYPE, title);
303-
await applyHerdr(title.display, title.branch, previousDisplayTitle, request, controller);
317+
await applyHerdr(title.display, title.branch, previousDisplayTitle, manual, request, controller);
304318
return title.display;
305319
} catch (error) {
306-
if (isCurrent(request, controller) && (manual || error instanceof RenameModelError)) {
320+
if (isCurrent(request, controller)) {
307321
ctx.ui.notify(error instanceof Error ? error.message : "Rename failed.", "warning");
308322
}
309323
return undefined;
@@ -341,8 +355,12 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
341355
if (!title || title !== saved?.display) return;
342356

343357
const { request, controller } = begin();
344-
void applyHerdr(title, saved.branch, saved.display, request, controller)
345-
.catch(() => undefined)
358+
void applyHerdr(title, saved.branch, saved.display, false, request, controller)
359+
.catch((error) => {
360+
if (isCurrent(request, controller)) {
361+
ctx.ui.notify(error instanceof Error ? error.message : "Rename failed.", "warning");
362+
}
363+
})
346364
.finally(() => finish(request, controller));
347365
});
348366

packages/pi-herdr-rename/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@henryqw/pi-herdr-rename",
3-
"version": "2.1.7",
3+
"version": "2.1.8",
44
"description": "Generate short Pi display titles and rename the current Herdr location.",
55
"keywords": [
66
"pi-package",

packages/pi-herdr-rename/test/rename.test.ts

Lines changed: 115 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import test from "node:test";
66
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7+
import { withWorktreeLock } from "@henryqw/pi-herdr";
78
import herdrRenameExtension from "../extensions/rename.ts";
89

910
type Handler = (event: any, ctx: ExtensionContext) => unknown;
@@ -213,8 +214,10 @@ test("manual rename disarms a pending automatic rename", async () => {
213214
});
214215

215216
test("saved display titles keep semantic branches, replace generated branches, and preserve custom workspace names", async () => {
216-
await withAgentDir(async () => {
217+
await withAgentDir(async (dir) => {
217218
process.env.HERDR_PANE_ID = "pane-1";
219+
const checkoutPath = join(dir, "worktree");
220+
await mkdir(checkoutPath);
218221
for (const [paneCount, workspaceName, isLinkedWorktree, currentBranch, existingBranches, renameWorkspace, gitMutation] of [
219222
[1, "worktree-brave-meadow-4aa8", true, "fix/title-length", [], true, undefined],
220223
[2, "lucky-field-f694", true, "feat/new-loader", [], true, undefined],
@@ -239,7 +242,7 @@ test("saved display titles keep semantic branches, replace generated branches, a
239242
return success(JSON.stringify({ result: { tab: { pane_count: paneCount } } }));
240243
}
241244
if (args[0] === "workspace" && args[1] === "get") {
242-
return success(JSON.stringify({ result: { workspace: { label: workspaceName, worktree: { checkout_path: "/repo/worktree", is_linked_worktree: isLinkedWorktree } } } }));
245+
return success(JSON.stringify({ result: { workspace: { label: workspaceName, worktree: { checkout_path: checkoutPath, is_linked_worktree: isLinkedWorktree } } } }));
243246
}
244247
return success("{}");
245248
},
@@ -257,7 +260,7 @@ test("saved display titles keep semantic branches, replace generated branches, a
257260
app.execCalls.filter((args) => (args[0] === "branch" && args[1] === "-m") || args[0] === "switch"),
258261
gitMutation ? [gitMutation] : [],
259262
);
260-
assert.deepEqual(gitCwds, Array(isLinkedWorktree ? 1 + Number(Boolean(gitMutation)) * 2 : 0).fill("/repo/worktree"));
263+
assert.deepEqual(gitCwds, Array(isLinkedWorktree ? 1 + Number(Boolean(gitMutation)) * 2 : 0).fill(checkoutPath));
261264
assert.equal(app.execCalls.filter((args) => args[0] === "for-each-ref").length, Number(Boolean(gitMutation)));
262265
if (gitMutation) {
263266
assert.ok(app.execCalls.some((args) => args.join("\0") === "for-each-ref\0--format=%(refname:short)\0refs/heads"));
@@ -270,38 +273,83 @@ test("saved display titles keep semantic branches, replace generated branches, a
270273
});
271274
});
272275

273-
test("manual rename updates its previous generated workspace title", async () => {
274-
await withAgentDir(async () => {
276+
test("manual rename updates generated and custom workspace titles", async () => {
277+
await withAgentDir(async (dir) => {
275278
process.env.HERDR_PANE_ID = "pane-1";
276-
const app = harness({
277-
sessionName: "Saved title",
278-
branch: [
279-
{ type: "custom", customType: "pi-herdr-rename/title", data: { display: "Saved title", branch: "fix/saved-title" } },
280-
{ type: "message", message: { role: "user", content: "update task logic" } },
281-
],
282-
complete: async () => response("refactor: update task logic"),
283-
exec: async (args) => {
284-
if (args.join("\0") === "branch\0--show-current") return success("fix/saved-title\n");
285-
if (args[0] === "pane" && args[1] === "get") {
286-
return success(JSON.stringify({ result: { pane: { tab_id: "tab-1", workspace_id: "workspace-1" } } }));
287-
}
288-
if (args[0] === "tab" && args[1] === "get") {
289-
return success(JSON.stringify({ result: { tab: { pane_count: 1 } } }));
290-
}
291-
if (args[0] === "workspace" && args[1] === "get") {
292-
return success(JSON.stringify({ result: { workspace: { label: "Saved title", worktree: { checkout_path: "/repo/worktree", is_linked_worktree: true } } } }));
293-
}
294-
return success("{}");
295-
},
296-
});
297-
await app.handlers.get("session_start")?.({}, app.ctx);
298-
await eventually(() => app.execCalls.some((args) => args[0] === "workspace" && args[1] === "get"));
299-
await app.commands.get("rename")?.("", app.ctx);
279+
const checkoutPath = join(dir, "worktree");
280+
await mkdir(checkoutPath);
281+
for (const workspaceName of ["Saved title", "Custom workspace"]) {
282+
const app = harness({
283+
sessionName: "Saved title",
284+
branch: [
285+
{ type: "custom", customType: "pi-herdr-rename/title", data: { display: "Saved title", branch: "fix/saved-title" } },
286+
{ type: "message", message: { role: "user", content: "update task logic" } },
287+
],
288+
complete: async () => response("refactor: update task logic"),
289+
exec: async (args) => {
290+
if (args.join("\0") === "branch\0--show-current") return success("fix/saved-title\n");
291+
if (args[0] === "pane" && args[1] === "get") {
292+
return success(JSON.stringify({ result: { pane: { tab_id: "tab-1", workspace_id: "workspace-1" } } }));
293+
}
294+
if (args[0] === "tab" && args[1] === "get") {
295+
return success(JSON.stringify({ result: { tab: { pane_count: 1 } } }));
296+
}
297+
if (args[0] === "workspace" && args[1] === "get") {
298+
return success(JSON.stringify({ result: { workspace: { label: workspaceName, worktree: { checkout_path: checkoutPath, is_linked_worktree: true } } } }));
299+
}
300+
return success("{}");
301+
},
302+
});
303+
await app.handlers.get("session_start")?.({}, app.ctx);
304+
await eventually(() => app.execCalls.some((args) => args[0] === "workspace" && args[1] === "get"));
305+
await app.commands.get("rename")?.("", app.ctx);
300306

301-
assert.deepEqual(
302-
app.execCalls.filter((args) => args[0] === "workspace" && args[1] === "rename"),
303-
[["workspace", "rename", "workspace-1", "Update task logic"]],
304-
);
307+
assert.deepEqual(
308+
app.execCalls.filter((args) => args[0] === "workspace" && args[1] === "rename"),
309+
[["workspace", "rename", "workspace-1", "Update task logic"]],
310+
);
311+
}
312+
});
313+
});
314+
315+
test("semantic branch mutation honors the shared worktree lock", async () => {
316+
await withAgentDir(async (dir) => {
317+
process.env.HERDR_PANE_ID = "pane-1";
318+
const checkoutPath = join(dir, "worktree");
319+
await mkdir(checkoutPath);
320+
let releaseLock!: () => void;
321+
const heldLock = withWorktreeLock(checkoutPath, () => new Promise<void>((resolve) => {
322+
releaseLock = resolve;
323+
}));
324+
await eventually(() => Boolean(releaseLock));
325+
try {
326+
const app = harness({
327+
sessionName: "Saved title",
328+
branch: [{ type: "message", message: { role: "user", content: "rename this" } }],
329+
exec: async (args) => {
330+
if (args.join("\0") === "branch\0--show-current") return success("worktree/generated\n");
331+
if (args[0] === "for-each-ref") return success("");
332+
if (args[0] === "pane" && args[1] === "get") {
333+
return success(JSON.stringify({ result: { pane: { tab_id: "tab-1", workspace_id: "workspace-1" } } }));
334+
}
335+
if (args[0] === "tab" && args[1] === "get") {
336+
return success(JSON.stringify({ result: { tab: { pane_count: 1 } } }));
337+
}
338+
if (args[0] === "workspace" && args[1] === "get") {
339+
return success(JSON.stringify({ result: { workspace: { label: "worktree-rapid-meadow-04ae", worktree: { checkout_path: checkoutPath, is_linked_worktree: true } } } }));
340+
}
341+
return success("{}");
342+
},
343+
});
344+
await app.handlers.get("session_start")?.({}, app.ctx);
345+
await app.commands.get("rename")?.("", app.ctx);
346+
347+
assert.equal(app.execCalls.filter((args) => args[0] === "branch" || args[0] === "for-each-ref" || args[0] === "switch").length, 0);
348+
assert.match(app.notifications.at(-1) ?? "", /lock/i);
349+
} finally {
350+
releaseLock();
351+
await heldLock;
352+
}
305353
});
306354
});
307355

@@ -318,6 +366,40 @@ test("existing and manually changed titles remain untouched", async () => {
318366
});
319367
await changed.handlers.get("session_start")?.({}, changed.ctx);
320368
assert.deepEqual(changed.execCalls, []);
369+
370+
const malformed = harness({
371+
sessionName: "One two three four five",
372+
branch: [{ type: "custom", customType: "pi-herdr-rename/title", data: { display: "One two three four five", branch: "fix/saved-title" } }],
373+
});
374+
await malformed.handlers.get("session_start")?.({}, malformed.ctx);
375+
await new Promise((resolve) => setTimeout(resolve, 0));
376+
assert.deepEqual(malformed.execCalls, []);
377+
});
378+
});
379+
380+
test("automatic and resumed Herdr failures warn", async () => {
381+
await withAgentDir(async () => {
382+
process.env.HERDR_PANE_ID = "pane-1";
383+
const exec = async (args: string[]) =>
384+
args[0] === "pane" && args[1] === "get"
385+
? { stdout: "", stderr: "gone", code: 7, killed: false }
386+
: success("{}");
387+
388+
const automatic = harness({ exec });
389+
await automatic.handlers.get("session_start")?.({}, automatic.ctx);
390+
automatic.handlers.get("input")?.({ source: "interactive", text: "prompt" }, automatic.ctx);
391+
await automatic.handlers.get("before_agent_start")?.({ prompt: "prompt" }, automatic.ctx);
392+
await eventually(() => automatic.notifications.length > 0);
393+
assert.match(automatic.notifications.at(-1) ?? "", /herdr pane get failed/);
394+
395+
const resumed = harness({
396+
sessionName: "Saved title",
397+
branch: [{ type: "custom", customType: "pi-herdr-rename/title", data: { display: "Saved title", branch: "fix/saved-title" } }],
398+
exec,
399+
});
400+
await resumed.handlers.get("session_start")?.({}, resumed.ctx);
401+
await eventually(() => resumed.notifications.length > 0);
402+
assert.match(resumed.notifications.at(-1) ?? "", /herdr pane get failed/);
321403
});
322404
});
323405

0 commit comments

Comments
 (0)