Skip to main content

tmx/
cli.rs

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    /// Kill all unattached tmux sessions
28    Cleanup,
29
30    /// List all tmux sessions
31    List,
32
33    /// Switch to a tmux session (interactive picker or by name)
34    Switch {
35        /// Optional session name. If provided, switch directly (creating if needed).
36        session: Option<String>,
37    },
38
39    /// Manage git worktrees for the current repo
40    Worktree {
41        #[command(subcommand)]
42        command: WorktreeCommand,
43    },
44
45    /// Emit shell integration code (defines the 'twt' wrapper)
46    #[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 name (currently only 'zsh')
52        shell: String,
53    },
54}
55
56#[derive(Subcommand, Debug)]
57enum WorktreeCommand {
58    /// Create a sibling git worktree for the current repo
59    #[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        /// New branch to create (also used to derive the directory name)
64        branch: String,
65
66        /// Create a tmux session at the new worktree and switch to it
67        #[arg(short = 't', long = "tmux")]
68        tmux: bool,
69
70        /// Base ref for the new branch (default: HEAD)
71        #[arg(long = "from")]
72        from: Option<String>,
73    },
74
75    /// List worktrees and print the chosen path on stdout
76    #[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        /// Worktree path or short branch name. Picker shown if omitted.
81        target: Option<String>,
82
83        /// Dump all worktrees as plain text instead of running the picker.
84        #[arg(long = "plain")]
85        plain: bool,
86    },
87
88    /// Remove a sibling git worktree (fuzzy picker if no target given)
89    #[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        /// Worktree path or short branch name. Picker shown if omitted.
94        target: Option<String>,
95
96        /// Kill conflicting sessions and pass --force to git.
97        #[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    // No --tmux: print the path on stdout so callers can `cd $(tmx worktree create ...)`.
214    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        // `list` is non-destructive, so the main worktree is a valid target.
251        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    // Step 1: locked preflight.
292    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    // Step 2: missing-target shortcut. Skip cwd / tmux checks and hand to git.
301    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    // Step 3: self-cwd guard.
308    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    // Step 4: self-session guard + conflict gate.
317    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}