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
22const 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
37pub 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
64fn 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
105pub fn list_contexts(cfg: &Config) -> Vec<Context> {
107 scan(&cfg.contexts_dir)
108}
109
110pub 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
118fn 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
142fn 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 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
195pub 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 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 Some(base) => (base.to_string(), true),
245 };
246 if let Some(parent) = path.parent() {
247 std::fs::create_dir_all(parent)?;
248 }
249 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 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 git_quiet(&["checkout", "--no-track", "-b", &branch], Some(&path))?;
296 }
297 Ok(())
298 };
299 if let Err(err) = checkout() {
300 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
313pub fn list_archived(cfg: &Config) -> Vec<Context> {
315 scan(&cfg.archive_dir)
316}
317
318pub 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
326pub 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
339fn 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 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
369pub 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
387pub 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
402pub 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
443pub 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
462pub 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 let (env, _origin) = registered();
510
511 let ctx = create(&env, "origin", "main");
512
513 assert_eq!(current_branch(&ctx), "main");
514 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 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 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 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 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 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 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 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}