1use std::path::Path;
2
3use anyhow::{Context, Result, bail};
4use clap::{Parser, Subcommand};
5
6use crate::agents;
7use crate::config::{self, Config};
8use crate::project::{self};
9use crate::shell_init;
10use crate::tmux;
11use crate::ui;
12use crate::worktree::{self, RemoveDecision};
13
14#[derive(Parser, Debug)]
15#[command(
16 name = "tmx",
17 about = "Project-aware tmux session manager",
18 long_about = "Scan project directories, show git status, and manage tmux sessions with project-type layouts.",
19 version
20)]
21struct Cli {
22 #[command(subcommand)]
23 command: Option<Command>,
24}
25
26#[derive(Subcommand, Debug)]
27enum Command {
28 #[command(
30 long_about = "Open a terminal dashboard listing every tmux pane and any Claude/Codex agent\nrunning inside it, plus codex-companion background jobs. Reads tmux + local\nstate (~/.claude/state/), no daemon required. Works the same in GUI tmux and\nover SSH.\n\nKeys:\n j/k or ↑/↓ navigate\n enter switch tmux client to the selected pane\n q / esc quit"
31 )]
32 Agents,
33
34 Cleanup,
36
37 List,
39
40 Switch {
42 session: Option<String>,
44 },
45
46 Worktree {
48 #[command(subcommand)]
49 command: WorktreeCommand,
50 },
51
52 #[command(
54 name = "shell-init",
55 long_about = "Emit shell initialization code that defines a 'twt' function\nrouting to 'tmx worktree …'.\n\nAdd to your shell rc:\n\n eval \"$(tmx shell-init zsh)\"\n\nThen:\n\n twt feat-x # create worktree + spawn/switch tmux session (default)\n twt feat-x --keep-current # create only; cd into the new worktree (no tmux switch)\n twt feat-x -p # create only; print path (no cd, no tmux switch)\n twt rm [target] # remove a worktree (picker if no target)\n twt list # picker → cd into the selected worktree\n twt list <target> # cd into the named worktree (path or short branch)\n twt list --plain # dump all worktrees as plain text (no cd)\n\nSupported shells: zsh."
56 )]
57 ShellInit {
58 shell: String,
60 },
61}
62
63#[derive(Subcommand, Debug)]
64enum WorktreeCommand {
65 #[command(
67 long_about = "Create a sibling git worktree, optionally run a post-create script,\nand by default spawn a tmux session at the new worktree and switch to it.\n\nWorktree is placed next to the source repo (sibling), named via the\n'worktree.naming' template in ~/.config/tmx/config.toml\n(default: '{repo}-{branch}'). Slashes in branch names are replaced\nwith dashes for the directory name.\n\nWith --keep-current, no tmux session is created and the worktree path is\nprinted on stdout (useful for shell wrappers that cd into the new path)."
68 )]
69 Create {
70 branch: String,
72
73 #[arg(long = "keep-current")]
76 keep_current: bool,
77
78 #[arg(long = "from")]
80 from: Option<String>,
81 },
82
83 #[command(
85 long_about = "Print a worktree path on stdout, intended for shell wrappers (`cd \"$(tmx worktree list)\"`).\n\nWith no target, an interactive picker is shown on stderr (so stdout stays clean for `$(...)` capture).\nWith a target (path or short branch), the resolved path is printed directly.\nWith --plain, all worktrees are dumped as 'path branch [flags]' lines on stdout."
86 )]
87 List {
88 target: Option<String>,
90
91 #[arg(long = "plain")]
93 plain: bool,
94 },
95
96 #[command(
98 long_about = "Remove a worktree by exact path or short branch name (e.g. 'feat-x').\nIf no target is provided, an interactive picker is shown.\n\nSafety rules (apply even with --force):\n - Locked worktrees are refused; run 'git worktree unlock' first.\n - The worktree containing the current working directory is refused.\n - The worktree containing the current tmux session is refused.\n\nWithout --force, any tmux session attached to the worktree blocks removal.\nWith --force, attached sessions are killed (by id) and 'git worktree remove --force'\nis used (covers dirty worktrees)."
99 )]
100 Rm {
101 target: Option<String>,
103
104 #[arg(short = 'f', long = "force")]
106 force: bool,
107 },
108}
109
110pub fn run() -> Result<()> {
111 config::ensure_config_dir();
112 let cli = Cli::parse();
113 match cli.command {
114 None => run_selector(),
115 Some(Command::Agents) => agents::run(),
116 Some(Command::Cleanup) => run_cleanup(),
117 Some(Command::List) => run_list(),
118 Some(Command::Switch { session }) => run_switch(session.as_deref()),
119 Some(Command::Worktree { command }) => match command {
120 WorktreeCommand::Create {
121 branch,
122 keep_current,
123 from,
124 } => run_worktree_create(&branch, keep_current, from.as_deref().unwrap_or("")),
125 WorktreeCommand::List { target, plain } => run_worktree_list(target.as_deref(), plain),
126 WorktreeCommand::Rm { target, force } => run_worktree_rm(target.as_deref(), force),
127 },
128 Some(Command::ShellInit { shell }) => run_shell_init(&shell),
129 }
130}
131
132fn run_selector() -> Result<()> {
133 let cfg = Config::load();
134 let projects = project::scan_projects(&cfg);
135 if projects.is_empty() {
136 println!("No projects found. Configure roots in ~/.config/tmx/config.toml");
137 println!("Example: roots = [\"~/dev\"]");
138 return Ok(());
139 }
140
141 let selected = ui::run_project_selector(projects)?;
142 let Some(project) = selected else {
143 return Ok(());
144 };
145 ui::open_project(&project)
146}
147
148fn run_cleanup() -> Result<()> {
149 let killed = tmux::cleanup_sessions().context("cleanup failed")?;
150 if killed.is_empty() {
151 println!("No unattached sessions to clean up.");
152 } else {
153 println!("Killed {} session(s): {}", killed.len(), killed.join(", "));
154 }
155 Ok(())
156}
157
158fn run_list() -> Result<()> {
159 let sessions = match tmux::list_sessions() {
160 Ok(s) => s,
161 Err(_) => {
162 println!("No tmux server running.");
163 return Ok(());
164 }
165 };
166 if sessions.is_empty() {
167 println!("No active sessions.");
168 return Ok(());
169 }
170 for s in sessions {
171 let attached = if s.attached { " (attached)" } else { "" };
172 println!(" {} — {} window(s){}", s.name, s.windows, attached);
173 }
174 Ok(())
175}
176
177fn run_switch(name: Option<&str>) -> Result<()> {
178 if let Some(name) = name {
179 if !tmux::session_exists(name) {
180 tmux::create_session(name, "").context("failed to create session")?;
181 }
182 return tmux::switch_session(name).context("failed to switch session");
183 }
184
185 let sessions = match tmux::list_sessions() {
186 Ok(s) if !s.is_empty() => s,
187 _ => {
188 println!("No active tmux sessions.");
189 return Ok(());
190 }
191 };
192
193 let Some(selected) = ui::run_session_switcher(sessions)? else {
194 return Ok(());
195 };
196 tmux::switch_session(&selected.name).context("failed to switch session")
197}
198
199fn run_worktree_create(branch: &str, keep_current: bool, from: &str) -> Result<()> {
200 let cfg = Config::load();
201 let res = worktree::create(
202 worktree::Options {
203 branch: branch.to_string(),
204 from: from.to_string(),
205 start_in: String::new(),
206 },
207 &cfg,
208 )?;
209
210 eprintln!("Created worktree: {}", res.worktree_path.display());
211 if !res.script_ran.is_empty() {
212 eprintln!("Ran post-create script: {}", res.script_ran);
213 }
214
215 if keep_current {
216 println!("{}", res.worktree_path.display());
218 return Ok(());
219 }
220
221 let session_name = tmux::safe_session_name(&res.repo_name, branch);
222 if !tmux::session_exists(&session_name) {
223 tmux::create_session(&session_name, &res.worktree_path.to_string_lossy())
224 .context("create tmux session")?;
225 }
226 tmux::switch_session(&session_name).context("switch tmux session")
227}
228
229fn run_worktree_list(target: Option<&str>, plain: bool) -> Result<()> {
230 let cwd = std::env::current_dir().context("resolve cwd")?;
231 let entries = worktree::list_entries(&cwd)?;
232
233 if plain {
234 for e in &entries {
235 let branch = e
236 .branch
237 .as_deref()
238 .map(worktree::short_branch)
239 .unwrap_or("(detached)");
240 let mut flags: Vec<&str> = Vec::new();
241 if e.is_main {
242 flags.push("main");
243 }
244 if e.locked {
245 flags.push("locked");
246 }
247 if e.prunable {
248 flags.push("prunable");
249 }
250 let suffix = if flags.is_empty() {
251 String::new()
252 } else {
253 format!(" [{}]", flags.join(","))
254 };
255 println!("{}\t{}{}", e.path.display(), branch, suffix);
256 }
257 return Ok(());
258 }
259
260 if let Some(t) = target {
261 let entry = worktree::resolve_target_any(&entries, t)?;
263 println!("{}", entry.path.display());
264 return Ok(());
265 }
266
267 if entries.is_empty() {
268 return Ok(());
269 }
270
271 let Some(selected) = ui::run_worktree_picker(entries)? else {
272 return Ok(());
273 };
274 println!("{}", selected.path.display());
275 Ok(())
276}
277
278fn run_worktree_rm(target: Option<&str>, force: bool) -> Result<()> {
279 let cwd = std::env::current_dir().context("resolve cwd")?;
280 let entries = worktree::list_entries(&cwd)?;
281
282 let entry: worktree::WorktreeEntry = match target {
283 Some(t) => worktree::resolve_target(&entries, t)?.clone(),
284 None => {
285 let removable: Vec<worktree::WorktreeEntry> =
286 entries.iter().filter(|e| !e.is_main).cloned().collect();
287 if removable.is_empty() {
288 println!("No removable worktrees.");
289 return Ok(());
290 }
291 let Some(selected) = ui::run_worktree_picker(removable)? else {
292 return Ok(());
293 };
294 selected
295 }
296 };
297
298 rm_preflight_and_remove(&entry, force, &cwd)
299}
300
301fn rm_preflight_and_remove(entry: &worktree::WorktreeEntry, force: bool, cwd: &Path) -> Result<()> {
302 if worktree::remove_decision(entry) == RemoveDecision::LockedRefuse {
304 bail!(
305 "worktree is locked: {}\nrun `git worktree unlock {}` first.",
306 entry.path.display(),
307 entry.path.display()
308 );
309 }
310
311 let target_canon = entry.path.canonicalize().ok();
313 if target_canon.is_none() {
314 return worktree::remove(&entry.path, force);
315 }
316 let target_canon = target_canon.unwrap();
317
318 let cwd_canon = cwd.canonicalize().context("canonicalize cwd")?;
320 if is_inside(&cwd_canon, &target_canon) {
321 bail!(
322 "refusing to remove worktree containing the current working directory.\ncd out of {} first, then re-run.",
323 entry.path.display()
324 );
325 }
326
327 let in_tmux = std::env::var_os("TMUX").is_some();
329 let current_session = if in_tmux {
330 match tmux::current_session_id() {
331 Some(id) => Some(id),
332 None => bail!("cannot determine current tmux session id; refusing to proceed"),
333 }
334 } else {
335 None
336 };
337
338 let attached: Vec<worktree::AttachedSession> =
339 match worktree::sessions_attached_to(&target_canon) {
340 Ok(v) => v,
341 Err(e) => {
342 if in_tmux {
343 bail!(
344 "tmux query failed while inside a tmux session; refusing to proceed: {e}"
345 );
346 } else {
347 Vec::new()
348 }
349 }
350 };
351
352 if let Some(cur) = current_session.as_deref()
353 && attached.iter().any(|s| s.id == cur)
354 {
355 bail!(
356 "refusing to remove worktree containing the current tmux session.\nswitch to a different session first, then re-run."
357 );
358 }
359
360 if !attached.is_empty() {
361 if !force {
362 let names: Vec<String> = attached.iter().map(|s| s.name.clone()).collect();
363 bail!(
364 "worktree {} is in use by tmux session(s): {}\npass --force to kill them and proceed",
365 entry.path.display(),
366 names.join(", ")
367 );
368 }
369 for s in &attached {
370 tmux::kill_session_id(&s.id)
371 .with_context(|| format!("kill-session {} ({})", s.id, s.name))?;
372 eprintln!("killed tmux session: {} ({})", s.name, s.id);
373 }
374 }
375
376 worktree::remove(&entry.path, force)
377}
378
379fn is_inside(child: &Path, parent: &Path) -> bool {
380 if child == parent {
381 return true;
382 }
383 let c = child.to_string_lossy();
384 let p = parent.to_string_lossy();
385 let p = p.trim_end_matches('/');
386 if let Some(rest) = c.strip_prefix(p)
387 && rest.starts_with('/')
388 {
389 return true;
390 }
391 false
392}
393
394fn run_shell_init(shell: &str) -> Result<()> {
395 match shell_init::emit(shell) {
396 Ok(text) => {
397 print!("{text}");
398 Ok(())
399 }
400 Err(e) => bail!("{e}"),
401 }
402}