Skip to main content

ctx_tui/
cli.rs

1use std::collections::HashMap;
2use std::io::Write;
3
4use clap::{Parser, Subcommand};
5
6use crate::config::{Config, ConfigError, load_config};
7use crate::contexts;
8use crate::errors::{CtxError, Result, msg};
9use crate::layout::accepted_keys;
10use crate::multiplexer::{Multiplexer, get_multiplexer};
11use crate::repos;
12use crate::status;
13use crate::{claude_hook, claude_trust};
14
15/// Injectable dependencies shared by all commands.
16pub struct Deps {
17    pub cfg: Config,
18    pub mux: std::sync::Arc<dyn Multiplexer>,
19}
20
21/// Both output streams, injectable so tests can capture them.
22pub struct Io<'a> {
23    pub out: &'a mut dyn Write,
24    pub err: &'a mut dyn Write,
25}
26
27#[derive(Parser)]
28#[command(
29    name = "ctx",
30    version,
31    about = "Manage repo-scoped work contexts.",
32    disable_help_subcommand = true
33)]
34struct Cli {
35    #[command(subcommand)]
36    command: Option<Commands>,
37}
38
39#[derive(Subcommand)]
40enum Commands {
41    /// Create a context: fresh checkout of REPO on a new local branch.
42    ///
43    /// NAME defaults to a random adjective-animal pair.
44    New {
45        repo: String,
46        name: Option<String>,
47        /// Base branch (default: the repo's default branch).
48        #[arg(short = 'b', long = "branch")]
49        base: Option<String>,
50        /// Pass a value to the layout's builtin panes (e.g. prompt=... for claude).
51        #[arg(short = 's', long = "set", value_name = "KEY=VALUE")]
52        assignments: Vec<String>,
53        /// Start the session without attaching to it.
54        #[arg(short = 'd', long)]
55        detach: bool,
56    },
57    /// Attach to a context's session, unarchiving it and recreating the session if needed.
58    Open { name: String },
59    /// List contexts with branch, dirtiness, and session state.
60    List {
61        /// List archived contexts instead.
62        #[arg(long)]
63        archived: bool,
64    },
65    /// Delete contexts, archived or not: kill their sessions and remove the checkouts.
66    Rm {
67        #[arg(required = true)]
68        names: Vec<String>,
69        /// Delete even with uncommitted or unpushed work.
70        #[arg(long)]
71        force: bool,
72    },
73    /// Archive contexts: kill their sessions and move the checkouts aside.
74    Archive {
75        names: Vec<String>,
76        /// Permanently delete all archived contexts.
77        #[arg(long)]
78        empty: bool,
79    },
80    /// Restore an archived context.
81    Unarchive { name: String },
82    /// Manage contexts and repos interactively.
83    Tui {
84        /// Exit the TUI after opening a context.
85        #[arg(long = "exit")]
86        exit_on_open: bool,
87    },
88    /// Print ctx usage docs for coding agents, ready to install as a skill.
89    AgentDocs,
90    /// Print the installed version's changelog.
91    Changelog,
92    /// Entry points backing the builtins.
93    #[command(subcommand, hide = true)]
94    Builtin(BuiltinCommands),
95    /// Manage registered repositories.
96    #[command(subcommand)]
97    Repo(RepoCommands),
98}
99
100#[derive(Subcommand)]
101enum BuiltinCommands {
102    /// Adapters for Claude Code.
103    #[command(subcommand)]
104    Claude(ClaudeCommands),
105}
106
107#[derive(Subcommand)]
108enum ClaudeCommands {
109    /// Feed the agent status column from a Claude Code hook event on stdin.
110    StatusHook,
111    /// Mark the current directory trusted so Claude Code skips its trust dialog.
112    Trust,
113}
114
115#[derive(Subcommand)]
116enum RepoCommands {
117    /// Register a repository by cloning a local bare mirror of it.
118    Add {
119        url: String,
120        /// Registry name (default: derived from URL).
121        #[arg(long)]
122        name: Option<String>,
123    },
124    /// List registered repositories.
125    List,
126    /// Show or set the repo new contexts are created in by default.
127    Default {
128        name: Option<String>,
129        /// Clear the default repo.
130        #[arg(long)]
131        clear: bool,
132    },
133    /// Remove registered repositories' mirrors (contexts are untouched).
134    Rm {
135        #[arg(required = true)]
136        names: Vec<String>,
137    },
138}
139
140fn parse_assignments(cfg: &Config, assignments: &[String]) -> Result<HashMap<String, String>> {
141    let accepted = accepted_keys(&cfg.layout);
142    let mut values = HashMap::new();
143    for assignment in assignments {
144        let Some((key, value)) = assignment.split_once('=') else {
145            return msg(format!("--set needs KEY=VALUE, got '{assignment}'"));
146        };
147        if key.is_empty() {
148            return msg(format!("--set needs KEY=VALUE, got '{assignment}'"));
149        }
150        if values.contains_key(key) {
151            return msg(format!("--set gives '{key}' twice"));
152        }
153        if !accepted.contains(key) {
154            return msg(format!("no builtin pane in the layout accepts '{key}'"));
155        }
156        values.insert(key.to_string(), value.to_string());
157    }
158    Ok(values)
159}
160
161fn create_and_open(
162    deps: &Deps,
163    io: &mut Io,
164    repo: &str,
165    name: Option<String>,
166    base: Option<&str>,
167    values: HashMap<String, String>,
168    detach: bool,
169) -> Result<()> {
170    let name = match name {
171        Some(name) => name,
172        None => contexts::random_name(&deps.cfg)?,
173    };
174    let ctx = contexts::create_context(&deps.cfg, repo, &name, base)?;
175    writeln!(
176        io.out,
177        "created {} at {} on {}",
178        ctx.qualified(),
179        ctx.path.display(),
180        contexts::current_branch(&ctx)
181    )?;
182    if detach {
183        deps.mux.create(&ctx, Some(&values))?;
184    } else {
185        deps.mux.open(&ctx, Some(&values))?;
186    }
187    Ok(())
188}
189
190fn cmd_open(deps: &Deps, io: &mut Io, name: &str) -> Result<()> {
191    let mut ctx = contexts::find_any(&deps.cfg, name)?;
192    if contexts::is_archived(&deps.cfg, &ctx) {
193        ctx = contexts::unarchive_context(&deps.cfg, &ctx)?;
194        writeln!(io.out, "unarchived {}", ctx.qualified())?;
195    }
196    deps.mux.open(&ctx, None)?;
197    Ok(())
198}
199
200/// Every context's status cells, fetched concurrently.
201fn all_status_cells(cfg: &Config, ctxs: &[contexts::Context]) -> Vec<Vec<String>> {
202    std::thread::scope(|scope| {
203        let handles: Vec<_> = ctxs
204            .iter()
205            .map(|ctx| scope.spawn(move || status::status_cells(cfg, ctx)))
206            .collect();
207        handles
208            .into_iter()
209            // A panicking provider blanks its row rather than killing the listing.
210            .map(|handle| {
211                handle
212                    .join()
213                    .unwrap_or_else(|_| vec![String::new(); 1 + cfg.status.len()])
214            })
215            .collect()
216    })
217}
218
219fn cmd_list(deps: &Deps, io: &mut Io, archived: bool) -> Result<()> {
220    let all_contexts = if archived {
221        contexts::list_archived(&deps.cfg)
222    } else {
223        contexts::list_contexts(&deps.cfg)
224    };
225    if all_contexts.is_empty() {
226        writeln!(
227            io.out,
228            "{}",
229            if archived {
230                "no archived contexts"
231            } else {
232                "no contexts"
233            }
234        )?;
235        return Ok(());
236    }
237    let mut header = vec![
238        "NAME".to_string(),
239        "REPO".to_string(),
240        "BRANCH".to_string(),
241        "STATUS".to_string(),
242    ];
243    header.extend(deps.cfg.status.iter().map(|s| s.name.to_uppercase()));
244    let mut rows = vec![header];
245    for (ctx, cells) in all_contexts
246        .iter()
247        .zip(all_status_cells(&deps.cfg, &all_contexts))
248    {
249        let mut row = vec![
250            ctx.name.clone(),
251            ctx.repo.clone(),
252            contexts::current_branch(ctx),
253        ];
254        row.extend(cells);
255        rows.push(row);
256    }
257    let columns = rows[0].len();
258    let widths: Vec<usize> = (0..columns)
259        .map(|column| {
260            rows.iter()
261                .map(|row| row[column].chars().count())
262                .max()
263                .unwrap_or(0)
264        })
265        .collect();
266    for row in rows {
267        let line = row
268            .iter()
269            .zip(&widths)
270            .map(|(cell, width)| {
271                let pad = width.saturating_sub(cell.chars().count());
272                format!("{cell}{}", " ".repeat(pad))
273            })
274            .collect::<Vec<_>>()
275            .join("  ");
276        writeln!(io.out, "{}", line.trim_end())?;
277    }
278    Ok(())
279}
280
281fn cmd_rm(deps: &Deps, io: &mut Io, names: &[String], force: bool) -> Result<i32> {
282    let mut failed = false;
283    for name in names {
284        if let Err(error) = remove_one(deps, io, name, force) {
285            writeln!(io.err, "error: {error}")?;
286            failed = true;
287        }
288    }
289    Ok(if failed { 1 } else { 0 })
290}
291
292/// Delete one context, reporting problems as errors instead of proceeding.
293fn remove_one(deps: &Deps, io: &mut Io, name: &str, force: bool) -> Result<()> {
294    let ctx = contexts::find_any(&deps.cfg, name)?;
295    if !force {
296        let mut problems = Vec::new();
297        if contexts::is_dirty(&ctx)? {
298            problems.push("uncommitted changes".to_string());
299        }
300        let unpushed = contexts::unpushed_commits(&ctx)?;
301        if !unpushed.is_empty() {
302            problems.push(format!("{} unpushed commit(s)", unpushed.len()));
303        }
304        if !problems.is_empty() {
305            return msg(format!(
306                "{} has {}; use --force to delete anyway",
307                ctx.qualified(),
308                problems.join(" and ")
309            ));
310        }
311    }
312    // Kill last: killing our own session takes this process down with it,
313    // so nothing after the kill is guaranteed to run. Kill even when the
314    // removal (or the echo — e.g. a broken pipe) fails half-way; the
315    // startup sweep finishes the removal.
316    let removed = contexts::remove_context(&ctx);
317    let echoed = match &removed {
318        Ok(()) => writeln!(io.out, "removed {}", ctx.qualified()),
319        Err(_) => Ok(()),
320    };
321    if deps.mux.exists(&ctx) {
322        deps.mux.kill(&ctx)?;
323    }
324    echoed?;
325    removed
326}
327
328fn cmd_archive(deps: &Deps, io: &mut Io, names: &[String], empty: bool) -> Result<i32> {
329    if empty {
330        if !names.is_empty() {
331            writeln!(io.err, "error: --empty takes no context names")?;
332            return Ok(2);
333        }
334        let count = contexts::list_archived(&deps.cfg).len();
335        contexts::empty_archive(&deps.cfg)?;
336        writeln!(io.out, "emptied archive ({count} context(s))")?;
337        return Ok(0);
338    }
339    if names.is_empty() {
340        writeln!(io.err, "error: provide context names or --empty")?;
341        return Ok(2);
342    }
343    let mut failed = false;
344    for name in names {
345        if let Err(error) = archive_one(deps, io, name) {
346            writeln!(io.err, "error: {error}")?;
347            failed = true;
348        }
349    }
350    Ok(if failed { 1 } else { 0 })
351}
352
353/// Archive one context, reporting problems as errors instead of proceeding.
354fn archive_one(deps: &Deps, io: &mut Io, name: &str) -> Result<()> {
355    let ctx = contexts::find_context(&deps.cfg, name)?;
356    contexts::archive_context(&deps.cfg, &ctx)?;
357    if deps.mux.exists(&ctx) {
358        deps.mux.kill(&ctx)?;
359    }
360    writeln!(io.out, "archived {}", ctx.qualified())?;
361    Ok(())
362}
363
364fn cmd_unarchive(deps: &Deps, io: &mut Io, name: &str) -> Result<()> {
365    let archived = contexts::find_archived(&deps.cfg, name)?;
366    let ctx = contexts::unarchive_context(&deps.cfg, &archived)?;
367    writeln!(io.out, "unarchived {}", ctx.qualified())?;
368    Ok(())
369}
370
371fn cmd_tui(deps: &Deps, io: &mut Io, exit_on_open: bool) -> Result<()> {
372    // When the multiplexer can open sessions in place (e.g. inside tmux),
373    // the TUI handles everything itself and exits with no request. The
374    // requests below are the fallback for terminal-takeover attaches.
375    let app = crate::tui::CtxTui::new(deps.cfg.clone(), deps.mux.clone(), exit_on_open);
376    match app.run()? {
377        Some(crate::tui::Request::Open { name }) => {
378            let ctx = contexts::find_context(&deps.cfg, &name)?;
379            deps.mux.open(&ctx, None)?;
380        }
381        Some(crate::tui::Request::New { repo, name, base }) => {
382            create_and_open(
383                deps,
384                io,
385                &repo,
386                Some(name),
387                base.as_deref(),
388                HashMap::new(),
389                false,
390            )?;
391        }
392        None => {}
393    }
394    Ok(())
395}
396
397fn cmd_repo(deps: &Deps, io: &mut Io, command: &RepoCommands) -> Result<i32> {
398    match command {
399        RepoCommands::Add { url, name } => {
400            writeln!(io.out, "cloning {url}")?;
401            let registered = repos::add_repo(&deps.cfg, url, name.as_deref())?;
402            writeln!(io.out, "registered '{registered}'")?;
403        }
404        RepoCommands::List => {
405            for name in repos::repo_names(&deps.cfg) {
406                writeln!(io.out, "{name}\t{}", repos::repo_url(&deps.cfg, &name)?)?;
407            }
408        }
409        RepoCommands::Default { name, clear } => {
410            if *clear {
411                if name.is_some() {
412                    writeln!(io.err, "error: --clear takes no repo name")?;
413                    return Ok(2);
414                }
415                repos::set_default_repo(&deps.cfg, None)?;
416                writeln!(io.out, "cleared default repo")?;
417                return Ok(0);
418            }
419            match name {
420                None => {
421                    let current = repos::default_repo(&deps.cfg);
422                    writeln!(
423                        io.out,
424                        "{}",
425                        current.as_deref().unwrap_or("no default repo")
426                    )?;
427                }
428                Some(name) => {
429                    repos::set_default_repo(&deps.cfg, Some(name))?;
430                    writeln!(io.out, "default repo is '{name}'")?;
431                }
432            }
433        }
434        RepoCommands::Rm { names } => {
435            let mut failed = false;
436            for name in names {
437                match repos::remove_repo(&deps.cfg, name) {
438                    Ok(()) => writeln!(io.out, "removed '{name}'")?,
439                    Err(error) => {
440                        writeln!(io.err, "error: {error}")?;
441                        failed = true;
442                    }
443                }
444            }
445            if failed {
446                return Ok(1);
447            }
448        }
449    }
450    Ok(0)
451}
452
453fn dispatch(cli: Cli, deps: &Deps, io: &mut Io) -> Result<i32> {
454    let command = match cli.command {
455        None => Commands::Tui {
456            exit_on_open: false,
457        },
458        Some(command) => command,
459    };
460    match command {
461        Commands::New {
462            repo,
463            name,
464            base,
465            assignments,
466            detach,
467        } => {
468            let values = parse_assignments(&deps.cfg, &assignments)?;
469            create_and_open(deps, io, &repo, name, base.as_deref(), values, detach)?;
470        }
471        Commands::Open { name } => cmd_open(deps, io, &name)?,
472        Commands::List { archived } => cmd_list(deps, io, archived)?,
473        Commands::Rm { names, force } => return cmd_rm(deps, io, &names, force),
474        Commands::Archive { names, empty } => return cmd_archive(deps, io, &names, empty),
475        Commands::Unarchive { name } => cmd_unarchive(deps, io, &name)?,
476        Commands::Tui { exit_on_open } => cmd_tui(deps, io, exit_on_open)?,
477        Commands::AgentDocs => {
478            write!(io.out, "{}", include_str!("agent_docs.md"))?;
479        }
480        Commands::Changelog => {
481            write!(io.out, "{}", include_str!("../CHANGELOG.md"))?;
482        }
483        Commands::Builtin(BuiltinCommands::Claude(command)) => {
484            let cwd = std::env::current_dir()?;
485            match command {
486                ClaudeCommands::StatusHook => {
487                    let mut raw = String::new();
488                    std::io::Read::read_to_string(&mut std::io::stdin(), &mut raw)?;
489                    claude_hook::handle(&raw, &cwd);
490                }
491                ClaudeCommands::Trust => claude_trust::trust(&cwd),
492            }
493        }
494        Commands::Repo(command) => return cmd_repo(deps, io, &command),
495    }
496    Ok(0)
497}
498
499fn report(err: &CtxError, io: &mut Io) -> i32 {
500    match err {
501        CtxError::Git(git) => {
502            let detail = git.stderr.as_deref().unwrap_or("").trim();
503            let message = format!("error: command failed ({})", git.argv.join(" "));
504            let _ = if detail.is_empty() {
505                writeln!(io.err, "{message}")
506            } else {
507                writeln!(io.err, "{message}\n{detail}")
508            };
509            git.code.unwrap_or(1)
510        }
511        other => {
512            let _ = writeln!(io.err, "error: {other}");
513            1
514        }
515    }
516}
517
518pub fn main() -> i32 {
519    // A SIGINT mid-transfer must fail the git call (killing its process
520    // group) so cleanup paths run, rather than kill this process outright.
521    crate::git::install_interrupt_handler();
522    let cli = Cli::parse();
523    let (out, err) = (std::io::stdout(), std::io::stderr());
524    let mut io = Io {
525        out: &mut out.lock(),
526        err: &mut err.lock(),
527    };
528    let cfg = match load_config(&crate::config::config_path()) {
529        Ok(cfg) => cfg,
530        Err(error) => {
531            let kind = match error {
532                ConfigError::Config(_) => "config",
533                ConfigError::Layout(_) => "layout",
534            };
535            let _ = writeln!(io.err, "error: invalid {kind}: {error}");
536            return 1;
537        }
538    };
539    let mux = get_multiplexer(cfg.multiplexer, cfg.layout.clone());
540    let deps = Deps { cfg, mux };
541    match dispatch(cli, &deps, &mut io) {
542        Ok(code) => code,
543        Err(error) => report(&error, &mut io),
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use std::sync::{Arc, Mutex};
550
551    use super::*;
552    use crate::contexts::Context;
553    use crate::multiplexer::MultiplexerError;
554    use crate::testutil::{TestEnv, commit_file, test_env};
555
556    /// Spy double: canned exists() answers plus a record of open/kill calls.
557    #[derive(Default)]
558    struct SpyState {
559        running: Vec<String>,
560        current: Option<String>,
561        opened: Vec<String>,
562        created: Vec<String>,
563        killed: Vec<String>,
564        path_present_at_kill: Option<bool>,
565        values: Vec<Option<HashMap<String, String>>>,
566    }
567
568    #[derive(Clone, Default)]
569    struct SpyMultiplexer(Arc<Mutex<SpyState>>);
570
571    impl SpyMultiplexer {
572        fn state(&self) -> std::sync::MutexGuard<'_, SpyState> {
573            self.0.lock().unwrap()
574        }
575    }
576
577    impl Multiplexer for SpyMultiplexer {
578        fn can_open_in_place(&self) -> bool {
579            true
580        }
581
582        fn exists(&self, ctx: &Context) -> bool {
583            self.state().running.contains(&ctx.qualified())
584        }
585
586        fn is_current(&self, ctx: &Context) -> bool {
587            self.state().current.as_deref() == Some(ctx.qualified().as_str())
588        }
589
590        fn create(
591            &self,
592            ctx: &Context,
593            values: Option<&HashMap<String, String>>,
594        ) -> std::result::Result<(), MultiplexerError> {
595            let mut state = self.state();
596            state.created.push(ctx.qualified());
597            state.values.push(values.cloned());
598            Ok(())
599        }
600
601        fn open(
602            &self,
603            ctx: &Context,
604            values: Option<&HashMap<String, String>>,
605        ) -> std::result::Result<(), MultiplexerError> {
606            let mut state = self.state();
607            state.opened.push(ctx.qualified());
608            state.values.push(values.cloned());
609            Ok(())
610        }
611
612        fn kill(&self, ctx: &Context) -> std::result::Result<(), MultiplexerError> {
613            let mut state = self.state();
614            state.path_present_at_kill = Some(ctx.path.exists());
615            state.killed.push(ctx.qualified());
616            Ok(())
617        }
618    }
619
620    struct Run {
621        code: i32,
622        out: String,
623        err: String,
624    }
625
626    fn invoke(args: &[&str], deps: &Deps) -> Run {
627        let mut argv = vec!["ctx"];
628        argv.extend(args);
629        let cli = match Cli::try_parse_from(&argv) {
630            Ok(cli) => cli,
631            Err(parse_error) => {
632                return Run {
633                    code: parse_error.exit_code(),
634                    out: String::new(),
635                    err: parse_error.to_string(),
636                };
637            }
638        };
639        let (mut out, mut err) = (Vec::new(), Vec::new());
640        let mut io = Io {
641            out: &mut out,
642            err: &mut err,
643        };
644        let code = match dispatch(cli, deps, &mut io) {
645            Ok(code) => code,
646            Err(error) => report(&error, &mut io),
647        };
648        Run {
649            code,
650            out: String::from_utf8(out).unwrap(),
651            err: String::from_utf8(err).unwrap(),
652        }
653    }
654
655    fn deps_for(env: &TestEnv) -> (Deps, SpyMultiplexer) {
656        let mux = SpyMultiplexer::default();
657        (
658            Deps {
659                cfg: env.cfg.clone(),
660                mux: std::sync::Arc::new(mux.clone()),
661            },
662            mux,
663        )
664    }
665
666    fn registered() -> (TestEnv, Deps, SpyMultiplexer) {
667        let env = test_env();
668        let origin = env.origin();
669        repos::add_repo(&env.cfg, &origin.to_string_lossy(), None).unwrap();
670        let (deps, mux) = deps_for(&env);
671        (env, deps, mux)
672    }
673
674    fn create(deps: &Deps, name: &str) -> Context {
675        contexts::create_context(&deps.cfg, "origin", name, None).unwrap()
676    }
677
678    #[test]
679    fn help() {
680        let env = test_env();
681        let (deps, _mux) = deps_for(&env);
682
683        let run = invoke(&["--help"], &deps);
684
685        assert_eq!(run.code, 0);
686    }
687
688    #[test]
689    fn version() {
690        let env = test_env();
691        let (deps, _mux) = deps_for(&env);
692
693        let run = invoke(&["--version"], &deps);
694
695        assert_eq!(run.code, 0);
696    }
697
698    #[test]
699    fn agent_docs_prints_the_spin_off_flow() {
700        let env = test_env();
701        let (deps, _mux) = deps_for(&env);
702
703        let run = invoke(&["agent-docs"], &deps);
704
705        assert_eq!(run.code, 0);
706        assert!(run.out.contains("ctx new <repo> <name> --detach"));
707        assert!(run.out.contains("--set prompt=\""));
708    }
709
710    #[test]
711    fn changelog_prints_release_sections() {
712        let env = test_env();
713        let (deps, _mux) = deps_for(&env);
714
715        let run = invoke(&["changelog"], &deps);
716
717        assert_eq!(run.code, 0);
718        assert!(run.out.starts_with("# Changelog"));
719        assert!(run.out.contains("## [0"));
720    }
721
722    #[test]
723    fn new_reports_the_created_context() {
724        let (_env, deps, _mux) = registered();
725
726        let run = invoke(&["new", "origin", "feat"], &deps);
727
728        assert_eq!(run.code, 0);
729        assert!(run.out.contains("created origin/feat"));
730    }
731
732    #[test]
733    fn new_without_a_name_generates_one() {
734        let (_env, deps, _mux) = registered();
735
736        let run = invoke(&["new", "origin"], &deps);
737
738        assert_eq!(run.code, 0);
739        assert!(run.out.contains("created origin/"));
740    }
741
742    #[test]
743    fn new_opens_a_session() {
744        let (_env, deps, mux) = registered();
745
746        invoke(&["new", "origin", "feat"], &deps);
747
748        assert_eq!(mux.state().opened, ["origin/feat"]);
749    }
750
751    #[test]
752    fn new_rejects_an_unregistered_repo() {
753        let env = test_env();
754        let (deps, _mux) = deps_for(&env);
755
756        let run = invoke(&["new", "nope", "feat"], &deps);
757
758        assert_eq!(run.code, 1);
759        assert!(run.err.contains("not registered"));
760    }
761
762    #[test]
763    fn new_detach_creates_the_session_without_opening() {
764        let (_env, deps, mux) = registered();
765
766        let run = invoke(&["new", "origin", "feat", "--detach"], &deps);
767
768        assert_eq!(run.code, 0);
769        let state = mux.state();
770        assert_eq!(state.created, ["origin/feat"]);
771        assert!(state.opened.is_empty());
772        assert_eq!(state.values, [Some(HashMap::new())]);
773    }
774
775    #[test]
776    fn new_marks_the_session_as_fresh() {
777        let (_env, deps, mux) = registered();
778
779        invoke(&["new", "origin", "feat"], &deps);
780
781        assert_eq!(mux.state().values, [Some(HashMap::new())]);
782    }
783
784    #[test]
785    fn open_marks_the_session_as_recreated() {
786        let (_env, deps, mux) = registered();
787        create(&deps, "feat");
788
789        invoke(&["open", "feat"], &deps);
790
791        assert_eq!(mux.state().values, [None]);
792    }
793
794    fn with_claude_layout(deps: Deps) -> Deps {
795        let mut cfg = deps.cfg;
796        cfg.layout = crate::layout::Node::Pane(crate::layout::Pane {
797            builtin: Some("claude".to_string()),
798            ..crate::layout::Pane::default()
799        });
800        Deps { cfg, mux: deps.mux }
801    }
802
803    #[test]
804    fn new_set_passes_values_to_the_session() {
805        let (_env, deps, mux) = registered();
806        let deps = with_claude_layout(deps);
807
808        let run = invoke(
809            &["new", "origin", "feat", "--set", "prompt=explore x"],
810            &deps,
811        );
812
813        assert_eq!(run.code, 0);
814        assert_eq!(
815            mux.state().values,
816            [Some(HashMap::from([(
817                "prompt".to_string(),
818                "explore x".to_string()
819            )]))]
820        );
821    }
822
823    #[test]
824    fn new_set_rejects_a_key_no_builtin_accepts() {
825        let (_env, deps, _mux) = registered();
826
827        let run = invoke(&["new", "origin", "feat", "--set", "prompt=x"], &deps);
828
829        assert_eq!(run.code, 1);
830        assert!(
831            run.err
832                .contains("no builtin pane in the layout accepts 'prompt'")
833        );
834    }
835
836    #[test]
837    fn new_set_rejects_a_malformed_assignment() {
838        let (_env, deps, _mux) = registered();
839
840        let run = invoke(&["new", "origin", "feat", "--set", "prompt"], &deps);
841
842        assert_eq!(run.code, 1);
843        assert!(run.err.contains("--set needs KEY=VALUE"));
844    }
845
846    #[test]
847    fn new_set_rejects_a_repeated_key() {
848        let (_env, deps, _mux) = registered();
849        let deps = with_claude_layout(deps);
850
851        let run = invoke(
852            &[
853                "new", "origin", "feat", "--set", "prompt=a", "--set", "prompt=b",
854            ],
855            &deps,
856        );
857
858        assert_eq!(run.code, 1);
859        assert!(run.err.contains("'prompt' twice"));
860    }
861
862    #[test]
863    fn new_rejects_an_invalid_name() {
864        let (_env, deps, _mux) = registered();
865
866        let run = invoke(&["new", "origin", "feat~1"], &deps);
867
868        assert_eq!(run.code, 1);
869        assert!(run.err.contains("valid branch name"));
870    }
871
872    #[test]
873    fn open_opens_the_context_session() {
874        let (_env, deps, mux) = registered();
875        create(&deps, "feat");
876
877        let run = invoke(&["open", "feat"], &deps);
878
879        assert_eq!(run.code, 0);
880        assert_eq!(mux.state().opened, ["origin/feat"]);
881    }
882
883    #[test]
884    fn open_unarchives_an_archived_context() {
885        let (_env, deps, mux) = registered();
886        contexts::archive_context(&deps.cfg, &create(&deps, "feat")).unwrap();
887
888        let run = invoke(&["open", "feat"], &deps);
889
890        assert_eq!(run.code, 0);
891        assert!(run.out.contains("unarchived origin/feat"));
892        assert_eq!(mux.state().opened, ["origin/feat"]);
893        assert!(contexts::list_archived(&deps.cfg).is_empty());
894        assert!(
895            contexts::find_context(&deps.cfg, "feat")
896                .unwrap()
897                .path
898                .exists()
899        );
900    }
901
902    #[test]
903    fn open_rejects_an_unknown_context() {
904        let env = test_env();
905        let (deps, _mux) = deps_for(&env);
906
907        let run = invoke(&["open", "feat"], &deps);
908
909        assert_eq!(run.code, 1);
910        assert!(run.err.contains("no context 'feat'"));
911    }
912
913    #[test]
914    fn list_without_contexts() {
915        let env = test_env();
916        let (deps, _mux) = deps_for(&env);
917
918        let run = invoke(&["list"], &deps);
919
920        assert_eq!(run.out, "no contexts\n");
921    }
922
923    #[test]
924    fn list_shows_each_context() {
925        let (_env, deps, _mux) = registered();
926        create(&deps, "feat");
927
928        let run = invoke(&["list"], &deps);
929
930        let lines: Vec<&str> = run.out.lines().collect();
931        let header: Vec<&str> = lines[0].split_whitespace().collect();
932        let row: Vec<&str> = lines[1].split_whitespace().collect();
933        assert_eq!(header, ["NAME", "REPO", "BRANCH", "STATUS"]);
934        assert_eq!(row, ["feat", "origin", "feat"]);
935    }
936
937    #[test]
938    fn list_adds_a_column_per_status_column() {
939        let (_env, mut deps, _mux) = registered();
940        deps.cfg.status = vec![crate::config::StatusColumn {
941            name: "claude".to_string(),
942            command: Some("echo working".to_string()),
943            builtin: None,
944            interval: None,
945        }];
946        create(&deps, "feat");
947
948        let run = invoke(&["list"], &deps);
949
950        let lines: Vec<&str> = run.out.lines().collect();
951        let header: Vec<&str> = lines[0].split_whitespace().collect();
952        let row: Vec<&str> = lines[1].split_whitespace().collect();
953        assert_eq!(header, ["NAME", "REPO", "BRANCH", "STATUS", "CLAUDE"]);
954        assert_eq!(row, ["feat", "origin", "feat", "working"]);
955    }
956
957    #[test]
958    fn list_marks_dirty_contexts() {
959        let (_env, deps, _mux) = registered();
960        let ctx = create(&deps, "feat");
961        std::fs::write(ctx.path.join("scratch.txt"), "x\n").unwrap();
962
963        let run = invoke(&["list"], &deps);
964
965        assert!(run.out.contains('*'));
966    }
967
968    #[test]
969    fn rm_deletes_the_checkout() {
970        let (_env, deps, _mux) = registered();
971        let ctx = create(&deps, "feat");
972
973        let run = invoke(&["rm", "feat"], &deps);
974
975        assert_eq!(run.code, 0);
976        assert!(run.out.contains("removed origin/feat"));
977        assert!(!ctx.path.exists());
978    }
979
980    #[test]
981    fn rm_kills_a_running_session() {
982        let (_env, deps, mux) = registered();
983        create(&deps, "feat");
984        mux.state().running.push("origin/feat".to_string());
985
986        invoke(&["rm", "feat"], &deps);
987
988        assert_eq!(mux.state().killed, ["origin/feat"]);
989    }
990
991    #[test]
992    fn rm_of_the_current_context_removes_before_the_kill() {
993        // Killing our own session ends this process; the removal must land first.
994        let (_env, deps, mux) = registered();
995        let ctx = create(&deps, "feat");
996        {
997            let mut state = mux.state();
998            state.running.push("origin/feat".to_string());
999            state.current = Some("origin/feat".to_string());
1000        }
1001
1002        let run = invoke(&["rm", "feat"], &deps);
1003
1004        assert_eq!(run.code, 0);
1005        assert!(!ctx.path.exists());
1006        let state = mux.state();
1007        assert_eq!(state.killed, ["origin/feat"]);
1008        assert_eq!(state.path_present_at_kill, Some(false));
1009    }
1010
1011    #[test]
1012    fn rm_kills_the_session_even_when_removal_fails() {
1013        use std::os::unix::fs::PermissionsExt;
1014
1015        let (_env, deps, mux) = registered();
1016        let ctx = create(&deps, "feat");
1017        mux.state().running.push("origin/feat".to_string());
1018        // A read-only directory inside the checkout makes the delete fail
1019        // half-way: its contents cannot be unlinked.
1020        let locked = ctx.path.join("locked");
1021        std::fs::create_dir(&locked).unwrap();
1022        std::fs::write(locked.join("pin"), "x\n").unwrap();
1023        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555)).unwrap();
1024
1025        let run = invoke(&["rm", "--force", "feat"], &deps);
1026
1027        // Unlock the leftover so the temp dir can be cleaned up.
1028        let leftover = ctx.path.with_file_name("feat.deleting").join("locked");
1029        std::fs::set_permissions(&leftover, std::fs::Permissions::from_mode(0o755)).unwrap();
1030
1031        assert_ne!(run.code, 0);
1032        assert_eq!(mux.state().killed, ["origin/feat"]);
1033    }
1034
1035    #[test]
1036    fn rm_kills_the_session_even_when_the_echo_fails() {
1037        // A broken stdout (e.g. `ctx rm a | head -0`) must not skip the kill.
1038        struct BrokenPipe;
1039        impl Write for BrokenPipe {
1040            fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
1041                Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe))
1042            }
1043            fn flush(&mut self) -> std::io::Result<()> {
1044                Ok(())
1045            }
1046        }
1047
1048        let (_env, deps, mux) = registered();
1049        create(&deps, "feat");
1050        mux.state().running.push("origin/feat".to_string());
1051        let (mut out, mut err) = (BrokenPipe, Vec::new());
1052        let mut io = Io {
1053            out: &mut out,
1054            err: &mut err,
1055        };
1056
1057        let result = remove_one(&deps, &mut io, "feat", false);
1058
1059        assert!(result.is_err(), "the broken pipe must still surface");
1060        assert_eq!(mux.state().killed, ["origin/feat"]);
1061    }
1062
1063    #[test]
1064    fn rm_refuses_unpushed_work() {
1065        let (_env, deps, _mux) = registered();
1066        let ctx = create(&deps, "feat");
1067        commit_file(&ctx.path, "work.txt", "x\n");
1068
1069        let run = invoke(&["rm", "feat"], &deps);
1070
1071        assert_eq!(run.code, 1);
1072        assert!(run.err.contains("unpushed commit"));
1073        assert!(ctx.path.exists());
1074    }
1075
1076    #[test]
1077    fn rm_force_overrides_the_guard() {
1078        let (_env, deps, _mux) = registered();
1079        let ctx = create(&deps, "feat");
1080        commit_file(&ctx.path, "work.txt", "x\n");
1081
1082        let run = invoke(&["rm", "--force", "feat"], &deps);
1083
1084        assert_eq!(run.code, 0);
1085        assert!(!ctx.path.exists());
1086    }
1087
1088    #[test]
1089    fn rm_archived_deletes_the_archived_checkout() {
1090        let (_env, deps, _mux) = registered();
1091        let archived = contexts::archive_context(&deps.cfg, &create(&deps, "feat")).unwrap();
1092
1093        let run = invoke(&["rm", "feat"], &deps);
1094
1095        assert_eq!(run.code, 0);
1096        assert!(!archived.path.exists());
1097    }
1098
1099    #[test]
1100    fn rm_archived_kills_a_lingering_session() {
1101        let (_env, deps, mux) = registered();
1102        contexts::archive_context(&deps.cfg, &create(&deps, "feat")).unwrap();
1103        mux.state().running.push("origin/feat".to_string());
1104
1105        let run = invoke(&["rm", "feat"], &deps);
1106
1107        assert_eq!(run.code, 0);
1108        assert_eq!(mux.state().killed, ["origin/feat"]);
1109    }
1110
1111    #[test]
1112    fn rm_archived_refuses_unpushed_work() {
1113        let (_env, deps, _mux) = registered();
1114        let ctx = create(&deps, "feat");
1115        commit_file(&ctx.path, "work.txt", "x\n");
1116        contexts::archive_context(&deps.cfg, &ctx).unwrap();
1117
1118        let run = invoke(&["rm", "feat"], &deps);
1119
1120        assert_eq!(run.code, 1);
1121        assert!(run.err.contains("unpushed commit"));
1122    }
1123
1124    #[test]
1125    fn rm_rejects_an_unknown_context() {
1126        let env = test_env();
1127        let (deps, _mux) = deps_for(&env);
1128
1129        let run = invoke(&["rm", "feat"], &deps);
1130
1131        assert_eq!(run.code, 1);
1132        assert!(run.err.contains("no context 'feat'"));
1133    }
1134
1135    #[test]
1136    fn archive_moves_the_context_and_kills_its_session() {
1137        let (_env, deps, mux) = registered();
1138        let ctx = create(&deps, "feat");
1139        mux.state().running.push("origin/feat".to_string());
1140
1141        let run = invoke(&["archive", "feat"], &deps);
1142
1143        assert_eq!(run.code, 0);
1144        assert!(run.out.contains("archived origin/feat"));
1145        assert_eq!(mux.state().killed, ["origin/feat"]);
1146        assert!(!ctx.path.exists());
1147        assert!(
1148            contexts::find_archived(&deps.cfg, "feat")
1149                .unwrap()
1150                .path
1151                .exists()
1152        );
1153    }
1154
1155    #[test]
1156    fn archive_rejects_an_unknown_context() {
1157        let env = test_env();
1158        let (deps, _mux) = deps_for(&env);
1159
1160        let run = invoke(&["archive", "feat"], &deps);
1161
1162        assert_eq!(run.code, 1);
1163        assert!(run.err.contains("no context 'feat'"));
1164    }
1165
1166    #[test]
1167    fn list_archived_without_archived_contexts() {
1168        let env = test_env();
1169        let (deps, _mux) = deps_for(&env);
1170
1171        let run = invoke(&["list", "--archived"], &deps);
1172
1173        assert_eq!(run.out, "no archived contexts\n");
1174    }
1175
1176    #[test]
1177    fn list_archived_shows_archived_contexts_only() {
1178        let (_env, deps, _mux) = registered();
1179        contexts::archive_context(&deps.cfg, &create(&deps, "cold")).unwrap();
1180        create(&deps, "hot");
1181
1182        let run = invoke(&["list", "--archived"], &deps);
1183
1184        let lines: Vec<&str> = run.out.lines().collect();
1185        let header: Vec<&str> = lines[0].split_whitespace().collect();
1186        let row: Vec<&str> = lines[1].split_whitespace().collect();
1187        assert_eq!(header, ["NAME", "REPO", "BRANCH", "STATUS"]);
1188        assert_eq!(&row[..2], ["cold", "origin"]);
1189    }
1190
1191    #[test]
1192    fn archive_empty_deletes_all_archived_contexts() {
1193        let (_env, deps, _mux) = registered();
1194        contexts::archive_context(&deps.cfg, &create(&deps, "cold")).unwrap();
1195        let kept = create(&deps, "hot");
1196
1197        let run = invoke(&["archive", "--empty"], &deps);
1198
1199        assert_eq!(run.code, 0);
1200        assert!(run.out.contains("emptied archive (1 context(s))"));
1201        assert!(contexts::list_archived(&deps.cfg).is_empty());
1202        assert!(kept.path.exists());
1203    }
1204
1205    #[test]
1206    fn archive_empty_rejects_names() {
1207        let env = test_env();
1208        let (deps, _mux) = deps_for(&env);
1209
1210        let run = invoke(&["archive", "--empty", "feat"], &deps);
1211
1212        assert_eq!(run.code, 2);
1213    }
1214
1215    #[test]
1216    fn unarchive_restores_the_context_without_opening() {
1217        let (_env, deps, mux) = registered();
1218        contexts::archive_context(&deps.cfg, &create(&deps, "feat")).unwrap();
1219
1220        let run = invoke(&["unarchive", "feat"], &deps);
1221
1222        assert_eq!(run.code, 0);
1223        assert!(run.out.contains("unarchived origin/feat"));
1224        assert!(mux.state().opened.is_empty());
1225        assert!(
1226            contexts::find_context(&deps.cfg, "feat")
1227                .unwrap()
1228                .path
1229                .exists()
1230        );
1231    }
1232
1233    #[test]
1234    fn unarchive_rejects_an_unknown_context() {
1235        let env = test_env();
1236        let (deps, _mux) = deps_for(&env);
1237
1238        let run = invoke(&["unarchive", "feat"], &deps);
1239
1240        assert_eq!(run.code, 1);
1241        assert!(run.err.contains("no archived context 'feat'"));
1242    }
1243
1244    #[test]
1245    fn repo_add_registers() {
1246        let env = test_env();
1247        let origin = env.origin();
1248        let (deps, _mux) = deps_for(&env);
1249
1250        let run = invoke(&["repo", "add", &origin.to_string_lossy()], &deps);
1251
1252        assert_eq!(run.code, 0);
1253        assert!(run.out.contains("registered 'origin'"));
1254    }
1255
1256    #[test]
1257    fn repo_list_shows_name_and_url() {
1258        let env = test_env();
1259        let origin = env.origin();
1260        repos::add_repo(&env.cfg, &origin.to_string_lossy(), None).unwrap();
1261        let (deps, _mux) = deps_for(&env);
1262
1263        let run = invoke(&["repo", "list"], &deps);
1264
1265        assert_eq!(run.out, format!("origin\t{}\n", origin.display()));
1266    }
1267
1268    #[test]
1269    fn repo_rm_unregisters() {
1270        let (_env, deps, _mux) = registered();
1271
1272        let run = invoke(&["repo", "rm", "origin"], &deps);
1273
1274        assert_eq!(run.code, 0);
1275        assert_eq!(invoke(&["repo", "list"], &deps).out, "");
1276    }
1277
1278    #[test]
1279    fn repo_rm_rejects_unregistered() {
1280        let env = test_env();
1281        let (deps, _mux) = deps_for(&env);
1282
1283        let run = invoke(&["repo", "rm", "nope"], &deps);
1284
1285        assert_eq!(run.code, 1);
1286        assert!(run.err.contains("not registered"));
1287    }
1288
1289    #[test]
1290    fn repo_default_sets_and_shows() {
1291        let (_env, deps, _mux) = registered();
1292
1293        let run = invoke(&["repo", "default", "origin"], &deps);
1294
1295        assert_eq!(run.code, 0);
1296        assert_eq!(invoke(&["repo", "default"], &deps).out, "origin\n");
1297    }
1298
1299    #[test]
1300    fn repo_default_clear() {
1301        let (_env, deps, _mux) = registered();
1302        invoke(&["repo", "default", "origin"], &deps);
1303
1304        let run = invoke(&["repo", "default", "--clear"], &deps);
1305
1306        assert_eq!(run.code, 0);
1307        assert_eq!(invoke(&["repo", "default"], &deps).out, "no default repo\n");
1308    }
1309
1310    #[test]
1311    fn repo_default_rejects_unregistered() {
1312        let env = test_env();
1313        let (deps, _mux) = deps_for(&env);
1314
1315        let run = invoke(&["repo", "default", "nope"], &deps);
1316
1317        assert_eq!(run.code, 1);
1318        assert!(run.err.contains("not registered"));
1319    }
1320}