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