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
15pub struct Deps {
17 pub cfg: Config,
18 pub mux: std::sync::Arc<dyn Multiplexer>,
19}
20
21pub 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 New {
45 repo: String,
46 name: Option<String>,
47 #[arg(short = 'b', long = "branch")]
49 base: Option<String>,
50 #[arg(short = 's', long = "set", value_name = "KEY=VALUE")]
52 assignments: Vec<String>,
53 #[arg(short = 'd', long)]
55 detach: bool,
56 },
57 Open { name: String },
59 List {
61 #[arg(long)]
63 archived: bool,
64 },
65 Rm {
67 #[arg(required = true)]
68 names: Vec<String>,
69 #[arg(long)]
71 force: bool,
72 },
73 Archive {
75 names: Vec<String>,
76 #[arg(long)]
78 empty: bool,
79 },
80 Unarchive { name: String },
82 Tui {
84 #[arg(long = "exit")]
86 exit_on_open: bool,
87 },
88 AgentDocs,
90 Changelog,
92 #[command(subcommand, hide = true)]
94 Builtin(BuiltinCommands),
95 #[command(subcommand)]
97 Repo(RepoCommands),
98}
99
100#[derive(Subcommand)]
101enum BuiltinCommands {
102 #[command(subcommand)]
104 Claude(ClaudeCommands),
105}
106
107#[derive(Subcommand)]
108enum ClaudeCommands {
109 StatusHook,
111 Trust,
113}
114
115#[derive(Subcommand)]
116enum RepoCommands {
117 Add {
119 url: String,
120 #[arg(long)]
122 name: Option<String>,
123 },
124 List,
126 Default {
128 name: Option<String>,
129 #[arg(long)]
131 clear: bool,
132 },
133 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
200fn 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 .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
292fn 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 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
353fn 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 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 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 #[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 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 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 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 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}