@@ -52,8 +52,15 @@ pub(crate) struct HookEvent {
5252pub ( crate ) enum HookEventPlan {
5353 SyncFiles ( Vec < String > ) ,
5454 AddBranch ( String ) ,
55- AddBranchAt { root : PathBuf , branch : String } ,
56- SyncCurrentBranch { branch : String , agent : HookAgent } ,
55+ AddBranchAt {
56+ root : PathBuf ,
57+ branch : String ,
58+ agent : HookAgent ,
59+ } ,
60+ SyncCurrentBranch {
61+ branch : String ,
62+ agent : HookAgent ,
63+ } ,
5764 DebouncedIncrementalSync ( HookAgent ) ,
5865 Noop ,
5966}
@@ -137,31 +144,112 @@ fn plan_shell_hook_event(
137144 return HookEventPlan :: Noop ;
138145 } ;
139146 let cwd = event. cwd . as_deref ( ) . unwrap_or ( project_root) ;
140- if !crate :: hooks:: cursor_shell_command_targets_project ( command, cwd, project_root) {
147+ let Some ( hook_project_root) = hook_project_root ( cwd, project_root) else {
148+ return HookEventPlan :: Noop ;
149+ } ;
150+ if !crate :: hooks:: cursor_shell_command_targets_project ( command, cwd, & hook_project_root) {
141151 return HookEventPlan :: Noop ;
142152 }
153+ let same_project = paths_same ( & hook_project_root, project_root) ;
154+ let hook_current_branch;
155+ let current_branch = if same_project {
156+ current_branch
157+ } else {
158+ hook_current_branch = crate :: branch:: current_branch ( & hook_project_root) ;
159+ hook_current_branch. as_deref ( )
160+ } ;
143161 match crate :: hooks:: cursor_shell_sync_plan_with_current_branch ( command, current_branch) {
144- crate :: hooks:: CursorShellSyncPlan :: BranchAdd ( branch) => HookEventPlan :: AddBranch ( branch) ,
162+ crate :: hooks:: CursorShellSyncPlan :: BranchAdd ( branch) => {
163+ branch_plan_for_root ( project_root, hook_project_root, branch, event. agent )
164+ }
145165 crate :: hooks:: CursorShellSyncPlan :: WorktreeBranchAdd {
146166 branch,
147167 worktree_path,
148168 } => HookEventPlan :: AddBranchAt {
149169 root : crate :: hooks:: resolve_worktree_add_root ( command, cwd, & worktree_path) ,
150170 branch,
171+ agent : event. agent ,
151172 } ,
152173 crate :: hooks:: CursorShellSyncPlan :: IncrementalSync => {
153174 HookEventPlan :: DebouncedIncrementalSync ( event. agent )
154175 }
155176 crate :: hooks:: CursorShellSyncPlan :: CurrentBranchSync ( branch) => {
156- HookEventPlan :: SyncCurrentBranch {
157- branch,
158- agent : event. agent ,
177+ if same_project {
178+ HookEventPlan :: SyncCurrentBranch {
179+ branch,
180+ agent : event. agent ,
181+ }
182+ } else {
183+ HookEventPlan :: AddBranchAt {
184+ root : hook_project_root,
185+ branch,
186+ agent : event. agent ,
187+ }
159188 }
160189 }
161190 crate :: hooks:: CursorShellSyncPlan :: Noop => HookEventPlan :: Noop ,
162191 }
163192}
164193
194+ fn hook_project_root ( cwd : & Path , project_root : & Path ) -> Option < PathBuf > {
195+ if let Some ( root) = crate :: config:: discover_project_root ( cwd) {
196+ if root_belongs_to_project ( & root, project_root) {
197+ return Some ( root) ;
198+ }
199+ return None ;
200+ }
201+ let Some ( worktree_root) = crate :: worktree:: git_worktree_root ( cwd) else {
202+ return path_is_inside ( cwd, project_root) . then ( || project_root. to_path_buf ( ) ) ;
203+ } ;
204+ if git_roots_share_common_dir ( & worktree_root, project_root) {
205+ Some ( worktree_root)
206+ } else {
207+ None
208+ }
209+ }
210+
211+ fn branch_plan_for_root (
212+ project_root : & Path ,
213+ hook_project_root : PathBuf ,
214+ branch : String ,
215+ agent : HookAgent ,
216+ ) -> HookEventPlan {
217+ if paths_same ( & hook_project_root, project_root) {
218+ HookEventPlan :: AddBranch ( branch)
219+ } else {
220+ HookEventPlan :: AddBranchAt {
221+ root : hook_project_root,
222+ branch,
223+ agent,
224+ }
225+ }
226+ }
227+
228+ fn root_belongs_to_project ( root : & Path , project_root : & Path ) -> bool {
229+ paths_same ( root, project_root) || git_roots_share_common_dir ( root, project_root)
230+ }
231+
232+ fn path_is_inside ( path : & Path , root : & Path ) -> bool {
233+ let path = path. canonicalize ( ) . unwrap_or_else ( |_| path. to_path_buf ( ) ) ;
234+ let root = root. canonicalize ( ) . unwrap_or_else ( |_| root. to_path_buf ( ) ) ;
235+ path. starts_with ( root)
236+ }
237+
238+ fn git_roots_share_common_dir ( a : & Path , b : & Path ) -> bool {
239+ let a_common = crate :: worktree:: git_common_dir ( a) ;
240+ let b_common = crate :: worktree:: git_common_dir ( b) ;
241+ a_common
242+ . as_ref ( )
243+ . zip ( b_common. as_ref ( ) )
244+ . is_some_and ( |( a_common, b_common) | paths_same ( a_common, b_common) )
245+ }
246+
247+ fn paths_same ( a : & Path , b : & Path ) -> bool {
248+ let a = a. canonicalize ( ) . unwrap_or_else ( |_| a. to_path_buf ( ) ) ;
249+ let b = b. canonicalize ( ) . unwrap_or_else ( |_| b. to_path_buf ( ) ) ;
250+ a == b
251+ }
252+
165253fn read_marker_secs ( path : & Path ) -> Option < i64 > {
166254 std:: fs:: read_to_string ( path)
167255 . ok ( ) ?
@@ -172,7 +260,8 @@ fn read_marker_secs(path: &Path) -> Option<i64> {
172260
173261#[ cfg( test) ]
174262mod tests {
175- use std:: path:: Path ;
263+ use std:: path:: { Path , PathBuf } ;
264+ use std:: process:: Command ;
176265
177266 use serde_json:: json;
178267
@@ -187,6 +276,87 @@ mod tests {
187276 }
188277 }
189278
279+ fn run_git ( cwd : & Path , args : & [ & str ] ) {
280+ let output = Command :: new ( "git" )
281+ . args ( args)
282+ . current_dir ( cwd)
283+ . output ( )
284+ . unwrap_or_else ( |e| panic ! ( "git {args:?} should run: {e}" ) ) ;
285+ assert ! (
286+ output. status. success( ) ,
287+ "git {:?} failed\n stdout:\n {}\n stderr:\n {}" ,
288+ args,
289+ String :: from_utf8_lossy( & output. stdout) ,
290+ String :: from_utf8_lossy( & output. stderr)
291+ ) ;
292+ }
293+
294+ #[ cfg( windows) ]
295+ fn git_test_root ( path : & Path ) -> std:: path:: PathBuf {
296+ path. to_path_buf ( )
297+ }
298+
299+ #[ cfg( not( windows) ) ]
300+ fn git_test_root ( path : & Path ) -> std:: path:: PathBuf {
301+ path. canonicalize ( )
302+ . unwrap_or_else ( |e| panic ! ( "tempdir should canonicalize: {e}" ) )
303+ }
304+
305+ fn setup_linked_session_worktree ( ) -> ( tempfile:: TempDir , PathBuf , PathBuf ) {
306+ let base = tempfile:: tempdir ( ) . unwrap_or_else ( |e| panic ! ( "tempdir should create: {e}" ) ) ;
307+ let base_root = git_test_root ( base. path ( ) ) ;
308+ let project_root = base_root. join ( "project" ) ;
309+ let worktree_root = base_root. join ( "session-worktree" ) ;
310+ std:: fs:: create_dir_all ( project_root. join ( "src" ) )
311+ . unwrap_or_else ( |e| panic ! ( "project dirs should create: {e}" ) ) ;
312+ std:: fs:: write ( project_root. join ( "src/lib.rs" ) , "pub fn marker() {}\n " )
313+ . unwrap_or_else ( |e| panic ! ( "source should write: {e}" ) ) ;
314+ run_git ( & project_root, & [ "init" , "-b" , "main" ] ) ;
315+ run_git ( & project_root, & [ "config" , "user.email" , "test@test.com" ] ) ;
316+ run_git ( & project_root, & [ "config" , "user.name" , "Test" ] ) ;
317+ run_git ( & project_root, & [ "add" , "." ] ) ;
318+ run_git ( & project_root, & [ "commit" , "-m" , "initial" ] ) ;
319+ let worktree_arg = worktree_root. to_string_lossy ( ) ;
320+ run_git (
321+ & project_root,
322+ & [
323+ "worktree" ,
324+ "add" ,
325+ worktree_arg. as_ref ( ) ,
326+ "-b" ,
327+ "feature/session" ,
328+ ] ,
329+ ) ;
330+ ( base, project_root, worktree_root)
331+ }
332+
333+ fn assert_add_branch_at ( plan : HookEventPlan , expected_root : & Path , expected_branch : & str ) {
334+ let HookEventPlan :: AddBranchAt {
335+ root,
336+ branch,
337+ agent,
338+ } = plan
339+ else {
340+ panic ! ( "expected AddBranchAt plan, got {plan:?}" ) ;
341+ } ;
342+ assert ! (
343+ super :: paths_same( & root, expected_root) ,
344+ "planned root {root:?} should match expected root {expected_root:?}"
345+ ) ;
346+ assert_eq ! ( branch, expected_branch) ;
347+ assert_eq ! ( agent, HookAgent :: Codex ) ;
348+ }
349+
350+ fn write_project_marker ( root : & Path ) {
351+ let db_path = crate :: config:: get_project_db_path ( root) ;
352+ let Some ( parent) = db_path. parent ( ) else {
353+ panic ! ( "db path should have parent" ) ;
354+ } ;
355+ std:: fs:: create_dir_all ( parent)
356+ . unwrap_or_else ( |e| panic ! ( "project marker dir should create: {e}" ) ) ;
357+ std:: fs:: write ( db_path, b"" ) . unwrap_or_else ( |e| panic ! ( "project marker should write: {e}" ) ) ;
358+ }
359+
190360 #[ test]
191361 fn parses_agent_and_event_kind_from_hook_notification ( ) {
192362 let params = json ! ( {
@@ -349,6 +519,32 @@ mod tests {
349519 ) ;
350520 }
351521
522+ #[ test]
523+ fn ignores_shell_branch_add_from_unrelated_project_root ( ) {
524+ let base = tempfile:: tempdir ( ) . unwrap_or_else ( |e| panic ! ( "tempdir should create: {e}" ) ) ;
525+ let project_root = base. path ( ) . join ( "project" ) ;
526+ let unrelated_root = base. path ( ) . join ( "unrelated" ) ;
527+ std:: fs:: create_dir_all ( & project_root)
528+ . unwrap_or_else ( |e| panic ! ( "project root should create: {e}" ) ) ;
529+ std:: fs:: create_dir_all ( & unrelated_root)
530+ . unwrap_or_else ( |e| panic ! ( "unrelated root should create: {e}" ) ) ;
531+ write_project_marker ( & project_root) ;
532+ write_project_marker ( & unrelated_root) ;
533+
534+ let params = json ! ( {
535+ "agent" : "codex" ,
536+ "event" : "postToolUseShell" ,
537+ "command" : "git switch feature/unrelated" ,
538+ "cwd" : unrelated_root
539+ } ) ;
540+ let event = parse_or_panic ( & params) ;
541+
542+ assert_eq ! (
543+ plan_hook_event( & event, & project_root, Some ( "feature/unrelated" ) ) ,
544+ HookEventPlan :: Noop
545+ ) ;
546+ }
547+
352548 #[ test]
353549 fn plans_worktree_add_against_new_worktree_root ( ) {
354550 let params = json ! ( {
@@ -364,6 +560,7 @@ mod tests {
364560 HookEventPlan :: AddBranchAt {
365561 root: Path :: new( "/tmp/wt" ) . to_path_buf( ) ,
366562 branch: "feature/daemon-hooks" . to_string( ) ,
563+ agent: HookAgent :: Codex ,
367564 }
368565 ) ;
369566 }
@@ -397,10 +594,49 @@ mod tests {
397594 HookEventPlan :: AddBranchAt {
398595 root: base_root. join( "wt" ) ,
399596 branch: "feature/daemon-hooks" . to_string( ) ,
597+ agent: HookAgent :: Codex ,
400598 }
401599 ) ;
402600 }
403601
602+ #[ test]
603+ fn plans_branch_switch_from_session_worktree_against_worktree_root ( ) {
604+ let ( _base, project_root, worktree_root) = setup_linked_session_worktree ( ) ;
605+
606+ let params = json ! ( {
607+ "agent" : "codex" ,
608+ "event" : "postToolUseShell" ,
609+ "command" : "git switch feature/session" ,
610+ "cwd" : worktree_root
611+ } ) ;
612+ let event = parse_or_panic ( & params) ;
613+
614+ assert_add_branch_at (
615+ plan_hook_event ( & event, & project_root, Some ( "main" ) ) ,
616+ & worktree_root,
617+ "feature/session" ,
618+ ) ;
619+ }
620+
621+ #[ test]
622+ fn plans_ambiguous_git_change_from_session_worktree_with_worktree_branch ( ) {
623+ let ( _base, project_root, worktree_root) = setup_linked_session_worktree ( ) ;
624+
625+ let params = json ! ( {
626+ "agent" : "codex" ,
627+ "event" : "postToolUseShell" ,
628+ "command" : "git pull --rebase" ,
629+ "cwd" : worktree_root
630+ } ) ;
631+ let event = parse_or_panic ( & params) ;
632+
633+ assert_add_branch_at (
634+ plan_hook_event ( & event, & project_root, Some ( "main" ) ) ,
635+ & worktree_root,
636+ "feature/session" ,
637+ ) ;
638+ }
639+
404640 #[ test]
405641 fn plans_workspace_open_as_current_branch_sync ( ) {
406642 let params = json ! ( {
0 commit comments