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