Skip to main content

ctx_tui/
contexts.rs

1use std::path::{Path, PathBuf};
2use std::time::SystemTime;
3
4use crate::config::Config;
5use crate::errors::{CtxError, Result, msg};
6use crate::git::{git, git_quiet, new_command};
7use crate::repos;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Context {
11    pub repo: String,
12    pub name: String,
13    pub path: PathBuf,
14}
15
16impl Context {
17    pub fn qualified(&self) -> String {
18        format!("{}/{}", self.repo, self.name)
19    }
20}
21
22// Checkouts are renamed to this suffix before removal, so an interrupted
23// delete leaves a marked corpse rather than a live-looking, half-gutted
24// context. `sweep_deleting` finishes such leftovers.
25const DELETING_SUFFIX: &str = ".deleting";
26
27pub fn context_path(cfg: &Config, repo: &str, name: &str) -> PathBuf {
28    cfg.contexts_dir.join(repo).join(name)
29}
30
31fn mtime(path: &Path) -> Option<SystemTime> {
32    std::fs::metadata(path)
33        .and_then(|meta| meta.modified())
34        .ok()
35}
36
37/// Proxy for the last interaction: the latest git activity in the checkout.
38///
39/// `.git/logs/HEAD` is appended to on commits, checkouts, and resets;
40/// `.git/index` is rewritten by staging and status refreshes. Neither sees
41/// plain file edits, but agent-driven work touches git constantly.
42pub fn last_active(ctx: &Context) -> SystemTime {
43    let candidates = [
44        ctx.path.join(".git").join("logs").join("HEAD"),
45        ctx.path.join(".git").join("index"),
46    ];
47    candidates
48        .iter()
49        .filter_map(|path| mtime(path))
50        .max()
51        .or_else(|| mtime(&ctx.path))
52        .unwrap_or(SystemTime::UNIX_EPOCH)
53}
54
55/// Whether a directory is a full clone, the only thing a context can be.
56///
57/// A `.git` directory means a full clone. A `.git` file marks a linked
58/// worktree (e.g. one an agent created next to a context); those are not
59/// contexts and nothing downstream can handle them.
60fn is_clone(path: &Path) -> bool {
61    path.join(".git").is_dir()
62}
63
64fn sorted_dirs(root: &Path) -> Vec<PathBuf> {
65    let Ok(entries) = std::fs::read_dir(root) else {
66        return Vec::new();
67    };
68    let mut paths: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
69    paths.sort();
70    paths
71}
72
73/// Contexts under a <root>/<repo>/<name> tree, most recently active first.
74fn scan(root: &Path) -> Vec<Context> {
75    if !root.is_dir() {
76        return Vec::new();
77    }
78    let mut found = Vec::new();
79    for repo_dir in sorted_dirs(root) {
80        if !repo_dir.is_dir() {
81            continue;
82        }
83        let repo = repo_dir
84            .file_name()
85            .unwrap_or_default()
86            .to_string_lossy()
87            .into_owned();
88        for ctx_dir in sorted_dirs(&repo_dir) {
89            let name = ctx_dir
90                .file_name()
91                .unwrap_or_default()
92                .to_string_lossy()
93                .into_owned();
94            if name.ends_with(DELETING_SUFFIX) {
95                continue;
96            }
97            if is_clone(&ctx_dir) {
98                found.push(Context {
99                    repo: repo.clone(),
100                    name,
101                    path: ctx_dir,
102                });
103            }
104        }
105    }
106    let mut keyed: Vec<(SystemTime, String, Context)> = found
107        .into_iter()
108        .map(|ctx| (last_active(&ctx), ctx.qualified(), ctx))
109        .collect();
110    keyed.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
111    keyed.into_iter().map(|(_, _, ctx)| ctx).collect()
112}
113
114/// All contexts, most recently active first.
115pub fn list_contexts(cfg: &Config) -> Vec<Context> {
116    scan(&cfg.contexts_dir)
117}
118
119/// Resolve a context name; names are globally unique.
120pub fn find_context(cfg: &Config, name: &str) -> Result<Context> {
121    list_contexts(cfg)
122        .into_iter()
123        .find(|ctx| ctx.name == name)
124        .ok_or_else(|| CtxError::Msg(format!("no context '{name}'")))
125}
126
127/// Names are unique across live and archived contexts alike.
128///
129/// Sharing a name with an archived context would leave that archive
130/// unrestorable, so the two pools compete for the same names.
131fn check_name_free(cfg: &Config, name: &str, exclude: Option<&Path>) -> Result<()> {
132    for ctx in list_contexts(cfg) {
133        if ctx.name == name && Some(ctx.path.as_path()) != exclude {
134            return msg(format!(
135                "context name '{name}' is already used by {}",
136                ctx.qualified()
137            ));
138        }
139    }
140    for ctx in list_archived(cfg) {
141        if ctx.name == name && Some(ctx.path.as_path()) != exclude {
142            return msg(format!(
143                "context name '{name}' is already used by archived {}",
144                ctx.qualified()
145            ));
146        }
147    }
148    Ok(())
149}
150
151/// Reject names that break the paths, branches, or commands they feed.
152fn check_name(name: &str, branch: &str) -> Result<()> {
153    if name.trim().is_empty() {
154        return msg("context name must not be empty");
155    }
156    if name.contains('/') || name == "." || name == ".." {
157        return msg(format!(
158            "context name '{name}' must be a single path component"
159        ));
160    }
161    if name.starts_with('-') {
162        return msg(format!("context name '{name}' must not start with '-'"));
163    }
164    if name.ends_with(DELETING_SUFFIX) {
165        return msg(format!(
166            "context name '{name}' must not end with '{DELETING_SUFFIX}'"
167        ));
168    }
169    // cwd="/": the process's own cwd may have been deleted under it.
170    let check = new_command("git")
171        .args(["check-ref-format", &format!("refs/heads/{branch}")])
172        .current_dir("/")
173        .output();
174    if !check.map(|output| output.status.success()).unwrap_or(false) {
175        return msg(format!(
176            "context name '{name}' does not make a valid branch name ('{branch}')"
177        ));
178    }
179    Ok(())
180}
181
182#[rustfmt::skip]
183const ADJECTIVES: &[&str] = &[
184    "amber", "bold", "brave", "breezy", "bright", "calm", "cheeky", "clever",
185    "cosmic", "cozy", "curious", "daring", "dapper", "dusty", "eager", "fancy",
186    "fluffy", "frosty", "fuzzy", "gentle", "golden", "happy", "hazy", "holy",
187    "humble", "jolly", "keen", "lively", "lucky", "mellow", "merry", "mighty",
188    "misty", "nimble", "perky", "plucky", "proud", "quiet", "rosy", "rusty",
189    "shiny", "sleepy", "sly", "snappy", "snug", "stormy", "sunny", "swift",
190    "vivid", "witty",
191];
192
193#[rustfmt::skip]
194const ANIMALS: &[&str] = &[
195    "badger", "bear", "beaver", "bison", "crane", "dingo", "dolphin", "eagle",
196    "falcon", "ferret", "finch", "fox", "gecko", "goose", "hare", "hawk",
197    "heron", "husky", "jaguar", "koala", "lemur", "llama", "lynx", "magpie",
198    "marmot", "moose", "narwhal", "otter", "owl", "panda", "pelican", "penguin",
199    "puffin", "quail", "raccoon", "raven", "robin", "seal", "sparrow", "stork",
200    "swan", "tiger", "toucan", "trout", "turtle", "walrus", "weasel", "wombat",
201    "wren", "yak",
202];
203
204/// A free adjective-animal name for when the user would rather not pick one.
205pub fn random_name(cfg: &Config) -> Result<String> {
206    random_name_from(cfg, ADJECTIVES, ANIMALS)
207}
208
209fn random_name_from(cfg: &Config, adjectives: &[&str], animals: &[&str]) -> Result<String> {
210    use rand::RngExt;
211
212    let taken: std::collections::HashSet<String> = list_contexts(cfg)
213        .into_iter()
214        .chain(list_archived(cfg))
215        .map(|ctx| ctx.name)
216        .collect();
217    let free: Vec<String> = adjectives
218        .iter()
219        .flat_map(|adjective| {
220            animals
221                .iter()
222                .map(move |animal| format!("{adjective}-{animal}"))
223        })
224        .filter(|name| !taken.contains(name))
225        .collect();
226    if free.is_empty() {
227        return msg("all generated names are taken; pick one yourself");
228    }
229    let pick = rand::rng().random_range(0..free.len());
230    Ok(free[pick].clone())
231}
232
233pub fn create_context(cfg: &Config, repo: &str, name: &str, base: Option<&str>) -> Result<Context> {
234    // Spaces are welcome in context names but not in branch names; dash them
235    // out. Anything else unfit for a branch is rejected, not rewritten.
236    let branch = format!("{}{}", cfg.branch_prefix, name.replace(' ', "-"));
237    check_name(name, &branch)?;
238    let mirror = repos::repo_path(cfg, repo);
239    if !mirror.exists() {
240        return msg(format!(
241            "repo '{repo}' is not registered (ctx repo add <url>)"
242        ));
243    }
244    check_name_free(cfg, name, None)?;
245    let path = context_path(cfg, repo, name);
246
247    let (base, fetch_base) = match base {
248        None => {
249            repos::update_repo(cfg, repo)?;
250            (repos::default_branch(cfg, repo)?, false)
251        }
252        // The mirror only carries the default branch; fetch the base into the context.
253        Some(base) => (base.to_string(), true),
254    };
255    if let Some(parent) = path.parent() {
256        std::fs::create_dir_all(parent)?;
257    }
258    // A directory that predates the clone is not ours to delete on failure.
259    let preexisting = path.exists();
260    let ctx = Context {
261        repo: repo.to_string(),
262        name: name.to_string(),
263        path: path.clone(),
264    };
265    let checkout = || -> Result<()> {
266        git_quiet(
267            &["clone", &mirror.to_string_lossy(), &path.to_string_lossy()],
268            None,
269        )?;
270        let url = repos::repo_url(cfg, repo)?;
271        git_quiet(&["remote", "set-url", "origin", &url], Some(&path))?;
272        if fetch_base && git_quiet(&["fetch", "origin", &base], Some(&path)).is_err() {
273            remove_context(&ctx)?;
274            return msg(format!("branch '{base}' not found on origin of '{repo}'"));
275        }
276        if !fetch_base
277            && !git_quiet(
278                &["for-each-ref", &format!("refs/heads/{branch}")],
279                Some(&path),
280            )?
281            .is_empty()
282        {
283            // The clone's default branch may already bear the requested name
284            // (e.g. a context named after it); adopt it instead of forking it.
285            git_quiet(&["checkout", &branch], Some(&path))?;
286        } else if !git_quiet(
287            &["for-each-ref", &format!("refs/remotes/origin/{base}")],
288            Some(&path),
289        )?
290        .is_empty()
291        {
292            git_quiet(
293                &[
294                    "checkout",
295                    "--no-track",
296                    "-b",
297                    &branch,
298                    &format!("origin/{base}"),
299                ],
300                Some(&path),
301            )?;
302        } else {
303            // An empty repo has no commit to branch from; start the work branch unborn.
304            git_quiet(&["checkout", "--no-track", "-b", &branch], Some(&path))?;
305        }
306        Ok(())
307    };
308    if let Err(err) = checkout() {
309        // A half-made checkout would squat on the name; leave nothing behind.
310        if !preexisting {
311            let _ = std::fs::remove_dir_all(&path);
312        }
313        return Err(err);
314    }
315    Ok(ctx)
316}
317
318pub fn archive_path(cfg: &Config, repo: &str, name: &str) -> PathBuf {
319    cfg.archive_dir.join(repo).join(name)
320}
321
322/// All archived contexts, most recently active first.
323pub fn list_archived(cfg: &Config) -> Vec<Context> {
324    scan(&cfg.archive_dir)
325}
326
327/// Resolve an archived context by name.
328pub fn find_archived(cfg: &Config, name: &str) -> Result<Context> {
329    list_archived(cfg)
330        .into_iter()
331        .find(|ctx| ctx.name == name)
332        .ok_or_else(|| CtxError::Msg(format!("no archived context '{name}'")))
333}
334
335/// Resolve a context name among live and archived contexts alike.
336pub fn find_any(cfg: &Config, name: &str) -> Result<Context> {
337    list_contexts(cfg)
338        .into_iter()
339        .chain(list_archived(cfg))
340        .find(|ctx| ctx.name == name)
341        .ok_or_else(|| CtxError::Msg(format!("no context '{name}'")))
342}
343
344pub fn is_archived(cfg: &Config, ctx: &Context) -> bool {
345    ctx.path.starts_with(&cfg.archive_dir)
346}
347
348/// Move a directory, falling back to copy-and-delete across filesystems.
349fn move_dir(src: &Path, dest: &Path) -> std::io::Result<()> {
350    match std::fs::rename(src, dest) {
351        Err(err) if err.kind() == std::io::ErrorKind::CrossesDevices => {
352            copy_tree(src, dest)?;
353            std::fs::remove_dir_all(src)
354        }
355        result => result,
356    }
357}
358
359fn copy_tree(src: &Path, dest: &Path) -> std::io::Result<()> {
360    std::fs::create_dir_all(dest)?;
361    for entry in std::fs::read_dir(src)? {
362        let entry = entry?;
363        let target = dest.join(entry.file_name());
364        let kind = entry.file_type()?;
365        if kind.is_symlink() {
366            // Recreate symlinks (checkouts carry them, and a dangling or
367            // directory link would fail a dereferencing copy).
368            std::os::unix::fs::symlink(std::fs::read_link(entry.path())?, &target)?;
369        } else if kind.is_dir() {
370            copy_tree(&entry.path(), &target)?;
371        } else {
372            std::fs::copy(entry.path(), &target)?;
373        }
374    }
375    Ok(())
376}
377
378/// Move a context's checkout into the archive.
379pub fn archive_context(cfg: &Config, ctx: &Context) -> Result<Context> {
380    check_name_free(cfg, &ctx.name, Some(&ctx.path))?;
381    let dest = archive_path(cfg, &ctx.repo, &ctx.name);
382    if dest.exists() {
383        return msg(format!("'{}' is already archived", ctx.qualified()));
384    }
385    if let Some(parent) = dest.parent() {
386        std::fs::create_dir_all(parent)?;
387    }
388    move_dir(&ctx.path, &dest)?;
389    Ok(Context {
390        repo: ctx.repo.clone(),
391        name: ctx.name.clone(),
392        path: dest,
393    })
394}
395
396/// Move an archived checkout back among the live contexts.
397pub fn unarchive_context(cfg: &Config, ctx: &Context) -> Result<Context> {
398    check_name_free(cfg, &ctx.name, Some(&ctx.path))?;
399    let dest = context_path(cfg, &ctx.repo, &ctx.name);
400    if let Some(parent) = dest.parent() {
401        std::fs::create_dir_all(parent)?;
402    }
403    move_dir(&ctx.path, &dest)?;
404    Ok(Context {
405        repo: ctx.repo.clone(),
406        name: ctx.name.clone(),
407        path: dest,
408    })
409}
410
411/// The checkout's branch, read from `.git/HEAD` to spare a subprocess.
412///
413/// Anything but a symbolic ref to a branch (e.g. a detached HEAD's raw
414/// hash) reads as no branch, like `git branch --show-current`. So does an
415/// unreadable HEAD: a checkout damaged on disk must not break listings.
416pub fn current_branch(ctx: &Context) -> String {
417    let Ok(head) = std::fs::read_to_string(ctx.path.join(".git").join("HEAD")) else {
418        return String::new();
419    };
420    head.trim()
421        .strip_prefix("ref: refs/heads/")
422        .unwrap_or("")
423        .to_string()
424}
425
426pub fn is_dirty(ctx: &Context) -> Result<bool> {
427    Ok(!git(&["status", "--porcelain"], Some(&ctx.path))?.is_empty())
428}
429
430pub fn unpushed_commits(ctx: &Context) -> Result<Vec<String>> {
431    let out = git(
432        &["log", "--branches", "--not", "--remotes", "--oneline"],
433        Some(&ctx.path),
434    )?;
435    if out.is_empty() {
436        return Ok(Vec::new());
437    }
438    Ok(out.lines().map(str::to_string).collect())
439}
440
441pub fn remove_context(ctx: &Context) -> Result<()> {
442    let name = ctx.path.file_name().unwrap_or_default().to_string_lossy();
443    let doomed = ctx.path.with_file_name(format!("{name}{DELETING_SUFFIX}"));
444    if doomed.exists() {
445        let _ = std::fs::remove_dir_all(&doomed);
446    }
447    std::fs::rename(&ctx.path, &doomed)?;
448    std::fs::remove_dir_all(&doomed)?;
449    Ok(())
450}
451
452/// Finish removals that a crash or kill interrupted mid-delete.
453pub fn sweep_deleting(cfg: &Config) {
454    for root in [&cfg.contexts_dir, &cfg.archive_dir] {
455        if !root.is_dir() {
456            continue;
457        }
458        for repo_dir in sorted_dirs(root) {
459            if !repo_dir.is_dir() {
460                continue;
461            }
462            for entry in sorted_dirs(&repo_dir) {
463                if entry.to_string_lossy().ends_with(DELETING_SUFFIX) && entry.is_dir() {
464                    let _ = std::fs::remove_dir_all(&entry);
465                }
466            }
467        }
468    }
469}
470
471/// Permanently delete every archived context.
472pub fn empty_archive(cfg: &Config) -> Result<()> {
473    for ctx in list_archived(cfg) {
474        remove_context(&ctx)?;
475    }
476    Ok(())
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::testutil::{TestEnv, commit_file, commit_lfs_file, git, lfs_available, test_env};
483
484    fn registered() -> (TestEnv, PathBuf) {
485        let env = test_env();
486        let origin = env.origin();
487        repos::add_repo(&env.cfg, &origin.to_string_lossy(), None).unwrap();
488        (env, origin)
489    }
490
491    fn create(env: &TestEnv, repo: &str, name: &str) -> Context {
492        create_context(&env.cfg, repo, name, None).unwrap()
493    }
494
495    #[test]
496    fn create_checks_out_the_repo() {
497        let (env, _origin) = registered();
498
499        let ctx = create(&env, "origin", "feat");
500
501        assert_eq!(ctx.path, env.cfg.contexts_dir.join("origin").join("feat"));
502        assert!(ctx.path.join("README.md").exists());
503    }
504
505    #[test]
506    fn create_starts_a_branch_named_after_the_context() {
507        let (env, _origin) = registered();
508
509        let ctx = create(&env, "origin", "feat");
510
511        assert_eq!(current_branch(&ctx), "feat");
512    }
513
514    #[test]
515    fn create_adopts_an_existing_local_branch() {
516        // "main" already exists in the fresh clone as its default branch. The
517        // context checks it out instead of failing to create a branch of that name.
518        let (env, _origin) = registered();
519
520        let ctx = create(&env, "origin", "main");
521
522        assert_eq!(current_branch(&ctx), "main");
523        // No second branch was forked; the existing one is all there is.
524        assert_eq!(
525            git(&["branch", "--format=%(refname:short)"], &ctx.path),
526            "main"
527        );
528    }
529
530    #[test]
531    fn create_applies_the_branch_prefix() {
532        let (mut env, _origin) = registered();
533        env.cfg.branch_prefix = "mb/".to_string();
534
535        let ctx = create(&env, "origin", "feat");
536
537        assert_eq!(current_branch(&ctx), "mb/feat");
538    }
539
540    #[test]
541    fn create_maps_name_spaces_to_branch_dashes() {
542        let (env, _origin) = registered();
543
544        let ctx = create(&env, "origin", "two words");
545
546        assert_eq!(current_branch(&ctx), "two-words");
547        assert_eq!(find_context(&env.cfg, "two words").unwrap(), ctx);
548    }
549
550    #[test]
551    fn create_smudges_lfs_files_from_the_mirror() {
552        if !lfs_available() {
553            return;
554        }
555        let env = test_env();
556        let origin = env.origin();
557        commit_lfs_file(&origin, "data.bin", "payload\n");
558        repos::add_repo(&env.cfg, &origin.to_string_lossy(), None).unwrap();
559        // Only the mirror's LFS store may serve the checkout.
560        std::fs::remove_dir_all(origin.join(".git").join("lfs")).unwrap();
561
562        let ctx = create(&env, "origin", "feat");
563
564        assert_eq!(
565            std::fs::read_to_string(ctx.path.join("data.bin")).unwrap(),
566            "payload\n"
567        );
568    }
569
570    #[test]
571    fn failed_clone_leaves_nothing_behind() {
572        if !lfs_available() {
573            return;
574        }
575        let env = test_env();
576        let origin = env.origin();
577        commit_lfs_file(&origin, "data.bin", "x\n");
578        repos::add_repo(&env.cfg, &origin.to_string_lossy(), None).unwrap();
579        // A mirror missing LFS objects fails the clone's checkout; the explicit
580        // base skips the mirror update that would repopulate the store.
581        std::fs::remove_dir_all(repos::repo_path(&env.cfg, "origin").join("lfs")).unwrap();
582
583        let result = create_context(&env.cfg, "origin", "feat", Some("main"));
584
585        assert!(matches!(result, Err(CtxError::Git(_))));
586        assert!(!env.cfg.contexts_dir.join("origin").join("feat").exists());
587    }
588
589    #[test]
590    fn failed_clone_spares_a_preexisting_directory() {
591        let (env, _origin) = registered();
592        let path = env.cfg.contexts_dir.join("origin").join("feat");
593        std::fs::create_dir_all(&path).unwrap();
594        std::fs::write(path.join("keep.txt"), "x\n").unwrap();
595
596        let result = create_context(&env.cfg, "origin", "feat", None);
597
598        assert!(matches!(result, Err(CtxError::Git(_))));
599        assert!(path.join("keep.txt").exists());
600    }
601
602    #[test]
603    fn create_rejects_an_empty_name() {
604        let (env, _origin) = registered();
605
606        for name in ["", "   "] {
607            let err = create_context(&env.cfg, "origin", name, None).expect_err("must reject");
608            assert!(err.to_string().contains("must not be empty"));
609        }
610    }
611
612    #[test]
613    fn create_rejects_path_like_names() {
614        let (env, _origin) = registered();
615
616        for name in ["a/b", ".", ".."] {
617            let err = create_context(&env.cfg, "origin", name, None).expect_err("must reject");
618            assert!(err.to_string().contains("single path component"));
619        }
620        assert_eq!(list_contexts(&env.cfg), vec![]);
621    }
622
623    #[test]
624    fn create_rejects_an_option_like_name() {
625        let (env, _origin) = registered();
626
627        let err = create_context(&env.cfg, "origin", "-feat", None).expect_err("must reject");
628
629        assert!(err.to_string().contains("must not start with '-'"));
630    }
631
632    #[test]
633    fn create_rejects_names_unfit_for_branches() {
634        let (env, _origin) = registered();
635
636        for name in [
637            "feat~1",
638            "a..b",
639            "what?",
640            "tab\there",
641            ".hidden",
642            "feat.lock",
643        ] {
644            let err = create_context(&env.cfg, "origin", name, None).expect_err("must reject");
645            assert!(err.to_string().contains("valid branch name"));
646        }
647        assert_eq!(list_contexts(&env.cfg), vec![]);
648    }
649
650    #[test]
651    fn create_points_the_remote_at_the_registered_url() {
652        let (env, origin) = registered();
653
654        let ctx = create(&env, "origin", "feat");
655
656        assert_eq!(
657            git(&["remote", "get-url", "origin"], &ctx.path),
658            origin.to_string_lossy()
659        );
660    }
661
662    #[test]
663    fn create_includes_the_latest_origin_commits() {
664        let (env, origin) = registered();
665        commit_file(&origin, "new.txt", "x\n");
666
667        let ctx = create(&env, "origin", "feat");
668
669        assert!(ctx.path.join("new.txt").exists());
670    }
671
672    #[test]
673    fn create_from_a_base_branch() {
674        let (env, origin) = registered();
675        git(&["branch", "other"], &origin);
676        commit_file(&origin, "on-main.txt", "x\n");
677
678        let ctx = create_context(&env.cfg, "origin", "feat", Some("other")).unwrap();
679
680        assert_eq!(current_branch(&ctx), "feat");
681        assert!(!ctx.path.join("on-main.txt").exists());
682    }
683
684    #[test]
685    fn create_with_a_missing_base_fails_cleanly() {
686        let (env, _origin) = registered();
687
688        let err =
689            create_context(&env.cfg, "origin", "feat", Some("nope")).expect_err("must reject");
690
691        assert!(err.to_string().contains("branch 'nope' not found"));
692        assert_eq!(list_contexts(&env.cfg), vec![]);
693    }
694
695    #[test]
696    fn create_rejects_a_taken_name() {
697        let (env, _origin) = registered();
698        create(&env, "origin", "feat");
699
700        let err = create_context(&env.cfg, "origin", "feat", None).expect_err("must reject");
701
702        assert!(err.to_string().contains("already used by origin/feat"));
703    }
704
705    #[test]
706    fn create_rejects_an_unregistered_repo() {
707        let env = test_env();
708
709        let err = create_context(&env.cfg, "nope", "feat", None).expect_err("must reject");
710
711        assert!(err.to_string().contains("not registered"));
712    }
713
714    #[test]
715    fn create_from_an_empty_repo() {
716        let env = test_env();
717        let origin = env.make_origin("empty", true);
718        repos::add_repo(&env.cfg, &origin.to_string_lossy(), None).unwrap();
719
720        let ctx = create(&env, "empty", "feat");
721
722        assert_eq!(current_branch(&ctx), "feat");
723        // Listing must not choke on the missing reflog/index of an unborn branch.
724        assert_eq!(list_contexts(&env.cfg), vec![ctx]);
725    }
726
727    #[test]
728    fn random_name_pairs_an_adjective_with_an_animal() {
729        let env = test_env();
730
731        let name = random_name(&env.cfg).unwrap();
732        let (adjective, animal) = name.split_once('-').unwrap();
733
734        assert!(ADJECTIVES.contains(&adjective));
735        assert!(ANIMALS.contains(&animal));
736    }
737
738    #[test]
739    fn random_name_avoids_live_and_archived_names() {
740        let (env, _origin) = registered();
741        let ctx = create(&env, "origin", "holy-tiger");
742        archive_context(&env.cfg, &ctx).unwrap();
743
744        let name = random_name_from(&env.cfg, &["holy"], &["tiger", "otter"]).unwrap();
745
746        assert_eq!(name, "holy-otter");
747    }
748
749    #[test]
750    fn random_name_fails_when_all_names_are_taken() {
751        let (env, _origin) = registered();
752        create(&env, "origin", "holy-tiger");
753
754        let err = random_name_from(&env.cfg, &["holy"], &["tiger"]).expect_err("must fail");
755
756        assert!(err.to_string().contains("all generated names are taken"));
757    }
758
759    #[test]
760    fn list_contexts_returns_created_contexts() {
761        let (env, _origin) = registered();
762
763        let created = create(&env, "origin", "feat");
764
765        assert_eq!(list_contexts(&env.cfg), vec![created]);
766    }
767
768    #[test]
769    fn list_contexts_ignores_agent_created_worktrees() {
770        // A `git worktree add` from inside a checkout lands a sibling
771        // directory whose `.git` is a file, not a clone; it is not a context.
772        let (env, _origin) = registered();
773        let ctx = create(&env, "origin", "task");
774        let phantom = env.cfg.contexts_dir.join("origin").join("phantom");
775        git(&["worktree", "add", &phantom.to_string_lossy()], &ctx.path);
776
777        assert_eq!(list_contexts(&env.cfg), vec![ctx]);
778    }
779
780    fn set_activity(ctx: &Context, when: i64) {
781        for rel in [".git/logs/HEAD", ".git/index"] {
782            set_mtime(&ctx.path.join(rel), when);
783        }
784    }
785
786    fn set_mtime(path: &Path, when: i64) {
787        let file = std::fs::OpenOptions::new().write(true).open(path).unwrap();
788        file.set_times(
789            std::fs::FileTimes::new()
790                .set_accessed(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(when as u64))
791                .set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(when as u64)),
792        )
793        .unwrap();
794    }
795
796    #[test]
797    fn list_contexts_sorts_most_recently_active_first() {
798        let (env, _origin) = registered();
799        let older = create(&env, "origin", "older");
800        let newer = create(&env, "origin", "newer");
801        set_activity(&older, 1_000);
802        set_activity(&newer, 2_000);
803
804        assert_eq!(list_contexts(&env.cfg), vec![newer.clone(), older.clone()]);
805
806        set_activity(&older, 3_000);
807
808        assert_eq!(list_contexts(&env.cfg), vec![older, newer]);
809    }
810
811    #[test]
812    fn index_activity_alone_counts_as_recency() {
813        let (env, _origin) = registered();
814        let quiet = create(&env, "origin", "quiet");
815        let staged = create(&env, "origin", "staged");
816        set_activity(&quiet, 1_000);
817        set_activity(&staged, 1_000);
818
819        set_mtime(&staged.path.join(".git").join("index"), 2_000);
820
821        assert_eq!(list_contexts(&env.cfg), vec![staged, quiet]);
822    }
823
824    #[test]
825    fn find_context_resolves_by_name() {
826        let (env, _origin) = registered();
827
828        let created = create(&env, "origin", "feat");
829
830        assert_eq!(find_context(&env.cfg, "feat").unwrap(), created);
831    }
832
833    #[test]
834    fn find_context_rejects_unknown_names() {
835        let env = test_env();
836
837        let err = find_context(&env.cfg, "feat").expect_err("must reject");
838
839        assert!(err.to_string().contains("no context 'feat'"));
840    }
841
842    #[test]
843    fn fresh_context_is_clean() {
844        let (env, _origin) = registered();
845
846        let ctx = create(&env, "origin", "feat");
847
848        assert!(!is_dirty(&ctx).unwrap());
849        assert_eq!(unpushed_commits(&ctx).unwrap(), Vec::<String>::new());
850    }
851
852    #[test]
853    fn uncommitted_file_makes_context_dirty() {
854        let (env, _origin) = registered();
855        let ctx = create(&env, "origin", "feat");
856
857        std::fs::write(ctx.path.join("scratch.txt"), "x\n").unwrap();
858
859        assert!(is_dirty(&ctx).unwrap());
860    }
861
862    #[test]
863    fn local_commit_counts_as_unpushed() {
864        let (env, _origin) = registered();
865        let ctx = create(&env, "origin", "feat");
866
867        commit_file(&ctx.path, "work.txt", "x\n");
868
869        assert_eq!(unpushed_commits(&ctx).unwrap().len(), 1);
870    }
871
872    #[test]
873    fn archive_moves_the_checkout_out_of_the_contexts() {
874        let (env, _origin) = registered();
875        let ctx = create(&env, "origin", "feat");
876
877        let archived = archive_context(&env.cfg, &ctx).unwrap();
878
879        assert_eq!(
880            archived.path,
881            env.cfg.archive_dir.join("origin").join("feat")
882        );
883        assert!(archived.path.join("README.md").exists());
884        assert_eq!(list_contexts(&env.cfg), vec![]);
885        assert_eq!(list_archived(&env.cfg), vec![archived]);
886    }
887
888    #[test]
889    fn archive_keeps_the_context_name_reserved() {
890        let (env, _origin) = registered();
891        let archived = archive_context(&env.cfg, &create(&env, "origin", "feat")).unwrap();
892
893        let err = create_context(&env.cfg, "origin", "feat", None).expect_err("must reject");
894
895        assert!(
896            err.to_string()
897                .contains("already used by archived origin/feat")
898        );
899        assert_eq!(list_archived(&env.cfg), vec![archived]);
900    }
901
902    #[test]
903    fn archive_refuses_to_move_onto_an_occupied_path() {
904        let (env, _origin) = registered();
905        let ctx = create(&env, "origin", "feat");
906        std::fs::create_dir_all(env.cfg.archive_dir.join("origin").join("feat")).unwrap();
907
908        let err = archive_context(&env.cfg, &ctx).expect_err("must reject");
909
910        assert!(err.to_string().contains("already archived"));
911        assert_eq!(list_contexts(&env.cfg), vec![ctx]);
912    }
913
914    #[test]
915    fn find_archived_resolves_by_name() {
916        let (env, _origin) = registered();
917        let archived = archive_context(&env.cfg, &create(&env, "origin", "feat")).unwrap();
918
919        assert_eq!(find_archived(&env.cfg, "feat").unwrap(), archived);
920    }
921
922    #[test]
923    fn find_any_resolves_live_and_archived_contexts() {
924        let (env, _origin) = registered();
925        let archived = archive_context(&env.cfg, &create(&env, "origin", "cold")).unwrap();
926        let live = create(&env, "origin", "hot");
927
928        assert_eq!(find_any(&env.cfg, "cold").unwrap(), archived);
929        assert_eq!(find_any(&env.cfg, "hot").unwrap(), live);
930    }
931
932    #[test]
933    fn find_any_rejects_unknown_names() {
934        let env = test_env();
935
936        let err = find_any(&env.cfg, "feat").expect_err("must reject");
937
938        assert!(err.to_string().contains("no context 'feat'"));
939    }
940
941    #[test]
942    fn find_archived_rejects_unknown_names() {
943        let env = test_env();
944
945        let err = find_archived(&env.cfg, "feat").expect_err("must reject");
946
947        assert!(err.to_string().contains("no archived context 'feat'"));
948    }
949
950    #[test]
951    fn unarchive_restores_the_context() {
952        let (env, _origin) = registered();
953        let created = create(&env, "origin", "feat");
954        let archived = archive_context(&env.cfg, &created).unwrap();
955
956        let restored = unarchive_context(&env.cfg, &archived).unwrap();
957
958        assert_eq!(restored, created);
959        assert_eq!(list_contexts(&env.cfg), vec![created]);
960        assert_eq!(list_archived(&env.cfg), vec![]);
961    }
962
963    #[test]
964    fn unarchive_rejects_a_name_taken_by_a_live_context() {
965        let (env, _origin) = registered();
966        let stale = archive_context(&env.cfg, &create(&env, "origin", "old")).unwrap();
967        let live = create(&env, "origin", "feat");
968        // Archives predating global uniqueness can still clash with a live name.
969        let clash = stale.path.with_file_name("feat");
970        std::fs::rename(&stale.path, &clash).unwrap();
971
972        let err = unarchive_context(
973            &env.cfg,
974            &Context {
975                repo: "origin".to_string(),
976                name: "feat".to_string(),
977                path: clash.clone(),
978            },
979        )
980        .expect_err("must reject");
981
982        assert!(err.to_string().contains("already used by origin/feat"));
983        assert!(live.path.exists());
984        assert!(clash.exists());
985    }
986
987    #[test]
988    fn copy_tree_preserves_symlinks() {
989        // The cross-filesystem fallback must move symlinks as symlinks:
990        // dereferencing breaks relative links, and dangling ones error.
991        let env = test_env();
992        let src = env.root().join("src");
993        std::fs::create_dir_all(src.join("sub")).unwrap();
994        std::fs::write(src.join("sub").join("real.txt"), "x\n").unwrap();
995        std::os::unix::fs::symlink("sub/real.txt", src.join("file-link")).unwrap();
996        std::os::unix::fs::symlink("sub", src.join("dir-link")).unwrap();
997        std::os::unix::fs::symlink("nowhere", src.join("dangling")).unwrap();
998
999        let dest = env.root().join("dest");
1000        copy_tree(&src, &dest).unwrap();
1001
1002        for (link, points_to) in [
1003            ("file-link", "sub/real.txt"),
1004            ("dir-link", "sub"),
1005            ("dangling", "nowhere"),
1006        ] {
1007            assert_eq!(
1008                std::fs::read_link(dest.join(link)).unwrap(),
1009                PathBuf::from(points_to)
1010            );
1011        }
1012    }
1013
1014    #[test]
1015    fn current_branch_tolerates_a_damaged_checkout() {
1016        // A context whose removal was interrupted must not break listings.
1017        let (env, _origin) = registered();
1018        let ctx = create(&env, "origin", "feat");
1019        std::fs::remove_file(ctx.path.join(".git").join("HEAD")).unwrap();
1020
1021        assert_eq!(current_branch(&ctx), "");
1022    }
1023
1024    #[test]
1025    fn remove_context_deletes_the_checkout() {
1026        let (env, _origin) = registered();
1027        let ctx = create(&env, "origin", "feat");
1028
1029        remove_context(&ctx).unwrap();
1030
1031        assert_eq!(list_contexts(&env.cfg), vec![]);
1032        assert_eq!(
1033            sorted_dirs(&env.cfg.contexts_dir.join("origin")),
1034            vec![] as Vec<PathBuf>
1035        );
1036    }
1037
1038    #[test]
1039    fn interrupted_removals_are_hidden_and_swept() {
1040        let (env, _origin) = registered();
1041        let kept = create(&env, "origin", "live");
1042        let doomed = create(&env, "origin", "feat");
1043        // An interrupted remove_context leaves the renamed checkout behind.
1044        let leftover = doomed.path.with_file_name("feat.deleting");
1045        std::fs::rename(&doomed.path, &leftover).unwrap();
1046
1047        assert_eq!(list_contexts(&env.cfg), vec![kept.clone()]);
1048
1049        sweep_deleting(&env.cfg);
1050
1051        assert!(!leftover.exists());
1052        assert_eq!(list_contexts(&env.cfg), vec![kept]);
1053    }
1054
1055    #[test]
1056    fn remove_context_replaces_a_stale_leftover() {
1057        let (env, _origin) = registered();
1058        let ctx = create(&env, "origin", "feat");
1059        let stale = ctx.path.with_file_name("feat.deleting");
1060        std::fs::create_dir(&stale).unwrap();
1061        std::fs::write(stale.join("junk"), "x\n").unwrap();
1062
1063        remove_context(&ctx).unwrap();
1064
1065        assert!(!ctx.path.exists());
1066        assert!(!stale.exists());
1067    }
1068
1069    #[test]
1070    fn create_rejects_names_reserved_for_deletion() {
1071        let (env, _origin) = registered();
1072
1073        let err =
1074            create_context(&env.cfg, "origin", "feat.deleting", None).expect_err("must reject");
1075
1076        assert!(err.to_string().contains(".deleting"));
1077    }
1078
1079    #[test]
1080    fn empty_archive_deletes_all_archived_contexts() {
1081        let (env, _origin) = registered();
1082        archive_context(&env.cfg, &create(&env, "origin", "one")).unwrap();
1083        archive_context(&env.cfg, &create(&env, "origin", "two")).unwrap();
1084        let kept = create(&env, "origin", "live");
1085
1086        empty_archive(&env.cfg).unwrap();
1087
1088        assert_eq!(list_archived(&env.cfg), vec![]);
1089        assert_eq!(list_contexts(&env.cfg), vec![kept]);
1090    }
1091}