use std::io::{Read, Write};
use std::path::PathBuf;
use std::process;
use iso_code::{AttachOptions, Config, CreateOptions, GcOptions, Manager};
#[derive(serde::Deserialize)]
struct ClaudeCodeHookPayload {
#[serde(default)]
session_id: String,
cwd: String,
#[serde(default)]
hook_event_name: String,
name: String,
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("[iso-code] Usage: wt <subcommand> [args]");
eprintln!("[iso-code] Subcommands: hook, list, create, delete, attach, gc");
process::exit(1);
}
match args[1].as_str() {
"hook" => run_hook(&args[2..]),
"list" => run_list(&args[2..]),
"create" => run_create(&args[2..]),
"delete" => run_delete(&args[2..]),
"attach" => run_attach(&args[2..]),
"gc" => run_gc(&args[2..]),
unknown => {
eprintln!("[iso-code] Unknown subcommand: {unknown}");
process::exit(1);
}
}
}
fn run_hook(args: &[String]) {
let mut setup = false;
let mut stdin_format = String::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--stdin-format" => {
if i + 1 < args.len() {
stdin_format = args[i + 1].clone();
i += 2;
} else {
eprintln!("[iso-code] --stdin-format requires a value");
process::exit(1);
}
}
"--setup" => {
setup = true;
i += 1;
}
unknown => {
eprintln!("[iso-code] Unknown flag: {unknown}");
process::exit(1);
}
}
}
if stdin_format != "claude-code" {
eprintln!("[iso-code] Unsupported --stdin-format: {stdin_format}. Only 'claude-code' is supported.");
process::exit(1);
}
let mut raw = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut raw) {
eprintln!("[iso-code] Failed to read stdin: {e}");
process::exit(1);
}
let payload: ClaudeCodeHookPayload = match serde_json::from_str(&raw) {
Ok(p) => p,
Err(e) => {
eprintln!("[iso-code] Failed to parse stdin JSON: {e}");
process::exit(1);
}
};
if payload.name.is_empty() {
eprintln!("[iso-code] 'name' field is required in hook payload");
process::exit(1);
}
if payload.cwd.is_empty() {
eprintln!("[iso-code] 'cwd' field is required in hook payload");
process::exit(1);
}
let repo_root = PathBuf::from(&payload.cwd);
eprintln!("[iso-code] hook received: session={} event={} branch={}",
payload.session_id, payload.hook_event_name, payload.name);
if payload.name.split('/').any(|seg| seg == ".." || seg == ".") {
eprintln!("[iso-code] branch name contains path traversal: {}", payload.name);
process::exit(1);
}
let mgr = match Manager::new(&repo_root, Config::default()) {
Ok(m) => m,
Err(e) => {
eprintln!("[iso-code] Failed to initialize Manager: {e}");
process::exit(1);
}
};
let path_slug = payload.name.replace('/', "-");
let wt_path = repo_root.parent()
.unwrap_or(&repo_root)
.join(&path_slug);
let mut opts = CreateOptions::default();
opts.setup = setup;
let (handle, _) = match mgr.create(&payload.name, &wt_path, opts) {
Ok(r) => r,
Err(e) => {
eprintln!("[iso-code] Failed to create worktree: {e}");
process::exit(1);
}
};
let path_str = handle.path.to_string_lossy();
let stdout = std::io::stdout();
let mut out = stdout.lock();
if let Err(e) = out
.write_all(path_str.as_bytes())
.and_then(|_| out.write_all(b"\n"))
.and_then(|_| out.flush())
{
eprintln!("[iso-code] Failed to write worktree path to stdout: {e}");
process::exit(1);
}
}
fn run_list(args: &[String]) {
let repo = args.first().map(PathBuf::from).unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
});
let mgr = match Manager::new(&repo, Config::default()) {
Ok(m) => m,
Err(e) => {
eprintln!("[iso-code] Error: {e}");
process::exit(1);
}
};
match mgr.list() {
Ok(worktrees) => {
for wt in worktrees {
println!("{} [{}] {:?}", wt.path.display(), wt.branch, wt.state);
}
}
Err(e) => {
eprintln!("[iso-code] Error: {e}");
process::exit(1);
}
}
}
fn run_create(args: &[String]) {
if args.len() < 2 {
eprintln!("[iso-code] Usage: wt create <branch> <path>");
process::exit(1);
}
let branch = &args[0];
let path = PathBuf::from(&args[1]);
let repo = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mgr = match Manager::new(&repo, Config::default()) {
Ok(m) => m,
Err(e) => {
eprintln!("[iso-code] Error: {e}");
process::exit(1);
}
};
match mgr.create(branch, &path, CreateOptions::default()) {
Ok((handle, _)) => {
println!("{}", handle.path.display());
}
Err(e) => {
eprintln!("[iso-code] Error: {e}");
process::exit(1);
}
}
}
fn run_delete(args: &[String]) {
if args.is_empty() {
eprintln!("[iso-code] Usage: wt delete <path>");
process::exit(1);
}
let path = PathBuf::from(&args[0]);
let repo = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mgr = match Manager::new(&repo, Config::default()) {
Ok(m) => m,
Err(e) => {
eprintln!("[iso-code] Error: {e}");
process::exit(1);
}
};
let worktrees = match mgr.list() {
Ok(wts) => wts,
Err(e) => {
eprintln!("[iso-code] Error listing worktrees: {e}");
process::exit(1);
}
};
let canon_path = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
let handle = match worktrees.iter().find(|wt| {
dunce::canonicalize(&wt.path)
.map(|p| p == canon_path)
.unwrap_or(wt.path == path)
}) {
Some(h) => h.clone(),
None => {
eprintln!("[iso-code] Worktree not found: {}", path.display());
process::exit(1);
}
};
if let Err(e) = mgr.delete(&handle, iso_code::DeleteOptions::default()) {
eprintln!("[iso-code] Error: {e}");
process::exit(1);
}
eprintln!("[iso-code] Deleted worktree: {}", path.display());
}
fn run_attach(args: &[String]) {
if args.is_empty() {
eprintln!("[iso-code] Usage: wt attach <path>");
process::exit(1);
}
let path = PathBuf::from(&args[0]);
let repo = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mgr = match Manager::new(&repo, Config::default()) {
Ok(m) => m,
Err(e) => {
eprintln!("[iso-code] Error: {e}");
process::exit(1);
}
};
match mgr.attach(&path, AttachOptions::default()) {
Ok(handle) => {
println!("{}", handle.path.display());
eprintln!(
"[iso-code] Attached {} (branch={}, session={})",
handle.path.display(),
handle.branch,
handle.session_uuid
);
}
Err(e) => {
eprintln!("[iso-code] Error: {e}");
process::exit(1);
}
}
}
fn run_gc(args: &[String]) {
let mut opts = GcOptions::default(); let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--run" => {
opts.dry_run = false;
i += 1;
}
"--force" => {
opts.force = true;
i += 1;
}
"--max-age-days" => {
if i + 1 >= args.len() {
eprintln!("[iso-code] --max-age-days requires a value");
process::exit(1);
}
opts.max_age_days = Some(match args[i + 1].parse() {
Ok(n) => n,
Err(_) => {
eprintln!("[iso-code] invalid --max-age-days: {}", args[i + 1]);
process::exit(1);
}
});
i += 2;
}
unknown => {
eprintln!("[iso-code] Unknown flag: {unknown}");
process::exit(1);
}
}
}
let repo = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mgr = match Manager::new(&repo, Config::default()) {
Ok(m) => m,
Err(e) => {
eprintln!("[iso-code] Error: {e}");
process::exit(1);
}
};
match mgr.gc(opts) {
Ok(report) => {
let tag = if report.dry_run { "dry-run" } else { "gc" };
for p in &report.orphans {
println!("[{tag}] orphan: {}", p.display());
}
for p in &report.evicted {
println!("[{tag}] evict: {}", p.display());
}
for p in &report.removed {
println!("[{tag}] remove: {}", p.display());
}
eprintln!(
"[iso-code] gc summary: orphans={} evicted={} removed={} freed_bytes={}{}",
report.orphans.len(),
report.evicted.len(),
report.removed.len(),
report.freed_bytes,
if report.dry_run { " (dry run)" } else { "" }
);
}
Err(e) => {
eprintln!("[iso-code] Error: {e}");
process::exit(1);
}
}
}