mod common;
use common::TestRepo;
use std::io::Write;
use std::process::{Command, Stdio};
fn json_path(p: &std::path::Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
fn run_create_with_stdin(cwd: &std::path::Path, stdin_payload: &str) -> std::process::Output {
let mut child = Command::new(TestRepo::cw_bin())
.arg("_claude-worktree-create")
.current_dir(cwd)
.env("CW_LAUNCH_METHOD", "foreground")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn _claude-worktree-create");
child
.stdin
.as_mut()
.expect("stdin handle")
.write_all(stdin_payload.as_bytes())
.expect("write stdin");
child.wait_with_output().expect("wait for child")
}
fn run_remove_with_stdin(cwd: &std::path::Path, stdin_payload: &str) -> std::process::Output {
let mut child = Command::new(TestRepo::cw_bin())
.arg("_claude-worktree-remove")
.current_dir(cwd)
.env("CW_LAUNCH_METHOD", "foreground")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn _claude-worktree-remove");
child
.stdin
.as_mut()
.expect("stdin handle")
.write_all(stdin_payload.as_bytes())
.expect("write stdin");
child.wait_with_output().expect("wait for child")
}
fn assert_branch_created(out: &std::process::Output, repo: &TestRepo, branch: &str) -> String {
assert!(
out.status.success(),
"_claude-worktree-create should succeed. stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines.len(),
1,
"stdout must be exactly one line, got: {:?}",
stdout
);
let wt_path = std::path::Path::new(lines[0]);
assert!(
wt_path.is_absolute(),
"output path must be absolute, got: {}",
lines[0]
);
assert!(
wt_path.is_dir(),
"worktree directory must exist at {}, but it does not",
lines[0]
);
let branches = Command::new("git")
.args([
"-C",
&repo.path().to_string_lossy(),
"branch",
"--list",
branch,
])
.output()
.expect("git branch --list");
assert!(
String::from_utf8_lossy(&branches.stdout).contains(branch),
"branch '{}' should have been created from worktree_name",
branch
);
lines[0].to_string()
}
#[test]
fn create_uses_worktree_name_as_branch() {
let repo = TestRepo::new();
let branch = "claude-create-test";
let payload = format!(
r#"{{"hook_event_name":"WorktreeCreate","session_id":"s1","cwd":"{cwd}","worktree_name":"{branch}","base_commit":"main","isolation_type":"worktree"}}"#,
cwd = json_path(repo.path()),
);
let out = run_create_with_stdin(repo.path(), &payload);
assert_branch_created(&out, &repo, branch);
}
#[test]
fn create_accepts_name_and_base_ref_aliases() {
let repo = TestRepo::new();
let branch = "claude-name-alias";
let payload = format!(
r#"{{"hook_event_name":"WorktreeCreate","session_id":"s1","transcript_path":"/tmp/t.jsonl","cwd":"{cwd}","name":"{branch}","base_ref":"main"}}"#,
cwd = json_path(repo.path()),
);
let out = run_create_with_stdin(repo.path(), &payload);
assert_branch_created(&out, &repo, branch);
}
#[test]
fn create_honors_explicit_worktree_path() {
let repo = TestRepo::new();
let branch = "claude-explicit-path";
let parent = tempfile::tempdir().expect("worktree parent tempdir");
let wt = parent.path().join(branch);
let payload = format!(
r#"{{"hook_event_name":"WorktreeCreate","cwd":"{cwd}","worktree_name":"{branch}","worktree_path":"{wt}"}}"#,
cwd = json_path(repo.path()),
wt = json_path(&wt),
);
let out = run_create_with_stdin(repo.path(), &payload);
let line = assert_branch_created(&out, &repo, branch);
let got = std::path::Path::new(&line);
assert_eq!(
got.canonicalize().unwrap_or_else(|_| got.to_path_buf()),
wt.canonicalize().unwrap_or(wt),
"worktree must land at the explicit path when one is supplied"
);
}
#[test]
fn create_ignores_unknown_fields() {
let repo = TestRepo::new();
let branch = "claude-unknown-fields";
let payload = format!(
r#"{{"hook_event_name":"WorktreeCreate","unknown_future_field":42,"cwd":"{cwd}","worktree_name":"{branch}"}}"#,
cwd = json_path(repo.path()),
);
let out = run_create_with_stdin(repo.path(), &payload);
assert_branch_created(&out, &repo, branch);
}
#[test]
fn create_without_base_commit_uses_default_base() {
let repo = TestRepo::new();
let branch = "claude-no-base-commit";
let payload = format!(
r#"{{"hook_event_name":"WorktreeCreate","cwd":"{cwd}","worktree_name":"{branch}"}}"#,
cwd = json_path(repo.path()),
);
let out = run_create_with_stdin(repo.path(), &payload);
assert_branch_created(&out, &repo, branch);
}
#[test]
fn create_invalid_json_exits_nonzero() {
let repo = TestRepo::new();
let out = run_create_with_stdin(repo.path(), "not-json-at-all");
assert!(
!out.status.success(),
"invalid JSON should cause non-zero exit"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.is_empty(),
"stderr should contain an error message, got empty"
);
}
#[test]
fn create_missing_worktree_name_exits_nonzero() {
let repo = TestRepo::new();
let payload = format!(
r#"{{"hook_event_name":"WorktreeCreate","cwd":"{}"}}"#,
json_path(repo.path())
);
let out = run_create_with_stdin(repo.path(), &payload);
assert!(
!out.status.success(),
"missing worktree_name should cause non-zero exit"
);
}
#[test]
fn create_empty_worktree_name_exits_nonzero() {
let repo = TestRepo::new();
let payload = format!(
r#"{{"hook_event_name":"WorktreeCreate","cwd":"{}","worktree_name":""}}"#,
json_path(repo.path())
);
let out = run_create_with_stdin(repo.path(), &payload);
assert!(
!out.status.success(),
"empty worktree_name should cause non-zero exit"
);
}
#[test]
fn create_empty_stdin_exits_nonzero() {
let repo = TestRepo::new();
let out = run_create_with_stdin(repo.path(), "");
assert!(
!out.status.success(),
"empty stdin should cause non-zero exit"
);
}
#[test]
fn remove_no_hook_exits_zero_silently() {
let repo = TestRepo::new();
let wt_path = repo.create_worktree("claude-remove-nohook");
let payload = format!(
r#"{{"hook_event_name":"WorktreeRemove","session_id":"s1","cwd":"{repo}","worktree_path":"{wt}","isolation_type":"worktree"}}"#,
repo = json_path(repo.path()),
wt = json_path(&wt_path)
);
let out = run_remove_with_stdin(repo.path(), &payload);
assert!(
out.status.success(),
"_claude-worktree-remove should exit 0; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.trim().is_empty(),
"stdout must be empty, got: {:?}",
stdout
);
}
#[cfg(unix)]
#[test]
fn remove_pre_rm_hook_fires_for_worktree() {
let repo = TestRepo::new();
let wt_path = repo.create_worktree("claude-remove-hook-fires");
let marker_dir = tempfile::tempdir().expect("marker tempdir");
let marker = marker_dir.path().join("pre_rm_fired");
let cfg = format!(
r#"{{"hooks":{{"pre_rm":"touch '{}'"}} }}"#,
marker.display()
);
std::fs::write(repo.path().join(".cwconfig.json"), &cfg).unwrap();
let payload = format!(
r#"{{"hook_event_name":"WorktreeRemove","cwd":"{repo}","worktree_path":"{wt}"}}"#,
repo = repo.path().display(),
wt = wt_path.display()
);
let out = run_remove_with_stdin(repo.path(), &payload);
assert!(
out.status.success(),
"_claude-worktree-remove should exit 0 when hook succeeds; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
marker.exists(),
"pre_rm hook should have created the marker file at {}",
marker.display()
);
}
#[cfg(unix)]
#[test]
fn remove_pre_rm_hook_failure_is_advisory_exits_zero() {
let repo = TestRepo::new();
let wt_path = repo.create_worktree("claude-remove-hook-fail");
std::fs::write(
repo.path().join(".cwconfig.json"),
r#"{"hooks":{"pre_rm":"exit 99"}}"#,
)
.unwrap();
let payload = format!(
r#"{{"hook_event_name":"WorktreeRemove","cwd":"{repo}","worktree_path":"{wt}"}}"#,
repo = repo.path().display(),
wt = wt_path.display()
);
let out = run_remove_with_stdin(repo.path(), &payload);
assert!(
out.status.success(),
"advisory pre_rm failure must not cause non-zero exit; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("pre_rm hook failed") || stderr.contains("advisory"),
"stderr should contain a warning about the hook failure, got: {:?}",
stderr
);
}
#[test]
fn remove_invalid_json_exits_nonzero() {
let repo = TestRepo::new();
let out = run_remove_with_stdin(repo.path(), "{bad json");
assert!(
!out.status.success(),
"invalid JSON should cause non-zero exit"
);
}