Skip to content

Commit 2e8c73f

Browse files
Merge pull request #230 from ScriptedAlchemy/codex/hook-routing-session-cwd
[codex] Route hook branch tracking by session worktree cwd
2 parents 0bee3a8 + adf9222 commit 2e8c73f

3 files changed

Lines changed: 265 additions & 14 deletions

File tree

src/daemon.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2428,7 +2428,7 @@ mod tests {
24282428
let (stream, _addr) = listener.accept().await.expect("accept daemon client");
24292429
let engine = engine.clone();
24302430
tasks.push(tokio::spawn(async move {
2431-
super::serve_socket_client(stream, engine)
2431+
Box::pin(super::serve_socket_client(stream, engine))
24322432
.await
24332433
.expect("serve proxied client");
24342434
}));

src/mcp/hook_events.rs

Lines changed: 244 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,15 @@ pub(crate) struct HookEvent {
5252
pub(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+
165253
fn 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)]
174262
mod 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\nstdout:\n{}\nstderr:\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!({

src/mcp/server.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1708,14 +1708,29 @@ impl McpServer {
17081708
Err(e) => eprintln!("[tracedecay] hook branch tracking failed: {e}"),
17091709
}
17101710
}
1711-
HookEventPlan::AddBranchAt { root, branch } => {
1712-
// The new worktree root is not this server's checkout, so no
1713-
// reopen or token-map refresh applies here (unlike AddBranch);
1714-
// branch tracking against the shared store is the whole job.
1711+
HookEventPlan::AddBranchAt {
1712+
root,
1713+
branch,
1714+
agent,
1715+
} => {
1716+
// The routed worktree root is not this server's checkout, so
1717+
// reopen/token-map refresh only applies after opening that root.
17151718
match self.add_hook_branch_tracking(&root, &branch, &cg).await {
1719+
Ok(crate::branch::BranchAddOutcome::AlreadyTracked) => {
1720+
match TraceDecay::open_with_options(&root, cg.open_options()).await {
1721+
Ok(worktree_cg) => {
1722+
self.run_hook_incremental_sync(Arc::new(worktree_cg), agent)
1723+
.await;
1724+
}
1725+
Err(e) => {
1726+
eprintln!(
1727+
"[tracedecay] hook worktree branch sync open failed: {e}"
1728+
);
1729+
}
1730+
}
1731+
}
17161732
Ok(
17171733
crate::branch::BranchAddOutcome::Added
1718-
| crate::branch::BranchAddOutcome::AlreadyTracked
17191734
| crate::branch::BranchAddOutcome::Deferred
17201735
| crate::branch::BranchAddOutcome::NotIndexed,
17211736
) => {}

0 commit comments

Comments
 (0)