@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44import { join } from "node:path" ;
55import test from "node:test" ;
66import type { ExtensionAPI , ExtensionContext } from "@earendil-works/pi-coding-agent" ;
7+ import { withWorktreeLock } from "@henryqw/pi-herdr" ;
78import herdrRenameExtension from "../extensions/rename.ts" ;
89
910type Handler = ( event : any , ctx : ExtensionContext ) => unknown ;
@@ -213,8 +214,10 @@ test("manual rename disarms a pending automatic rename", async () => {
213214} ) ;
214215
215216test ( "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" ) ) ;
@@ -271,8 +274,10 @@ test("saved display titles keep semantic branches, replace generated branches, a
271274} ) ;
272275
273276test ( "manual rename updates generated and custom workspace titles" , async ( ) => {
274- await withAgentDir ( async ( ) => {
277+ await withAgentDir ( async ( dir ) => {
275278 process . env . HERDR_PANE_ID = "pane-1" ;
279+ const checkoutPath = join ( dir , "worktree" ) ;
280+ await mkdir ( checkoutPath ) ;
276281 for ( const workspaceName of [ "Saved title" , "Custom workspace" ] ) {
277282 const app = harness ( {
278283 sessionName : "Saved title" ,
@@ -290,7 +295,7 @@ test("manual rename updates generated and custom workspace titles", async () =>
290295 return success ( JSON . stringify ( { result : { tab : { pane_count : 1 } } } ) ) ;
291296 }
292297 if ( args [ 0 ] === "workspace" && args [ 1 ] === "get" ) {
293- return success ( JSON . stringify ( { result : { workspace : { label : workspaceName , worktree : { checkout_path : "/repo/worktree" , is_linked_worktree : true } } } } ) ) ;
298+ return success ( JSON . stringify ( { result : { workspace : { label : workspaceName , worktree : { checkout_path : checkoutPath , is_linked_worktree : true } } } } ) ) ;
294299 }
295300 return success ( "{}" ) ;
296301 } ,
@@ -307,6 +312,47 @@ test("manual rename updates generated and custom workspace titles", async () =>
307312 } ) ;
308313} ) ;
309314
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 ) ?? "" , / l o c k / i) ;
349+ } finally {
350+ releaseLock ( ) ;
351+ await heldLock ;
352+ }
353+ } ) ;
354+ } ) ;
355+
310356test ( "existing and manually changed titles remain untouched" , async ( ) => {
311357 await withAgentDir ( async ( ) => {
312358 process . env . HERDR_PANE_ID = "pane-1" ;
@@ -320,6 +366,40 @@ test("existing and manually changed titles remain untouched", async () => {
320366 } ) ;
321367 await changed . handlers . get ( "session_start" ) ?.( { } , changed . ctx ) ;
322368 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 ) ?? "" , / h e r d r p a n e g e t f a i l e d / ) ;
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 ) ?? "" , / h e r d r p a n e g e t f a i l e d / ) ;
323403 } ) ;
324404} ) ;
325405
0 commit comments