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 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
73fn 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
114pub fn list_contexts(cfg: &Config) -> Vec<Context> {
116 scan(&cfg.contexts_dir)
117}
118
119pub 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
127fn 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
151fn 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 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
204pub 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 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 Some(base) => (base.to_string(), true),
254 };
255 if let Some(parent) = path.parent() {
256 std::fs::create_dir_all(parent)?;
257 }
258 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 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 git_quiet(&["checkout", "--no-track", "-b", &branch], Some(&path))?;
305 }
306 Ok(())
307 };
308 if let Err(err) = checkout() {
309 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
322pub fn list_archived(cfg: &Config) -> Vec<Context> {
324 scan(&cfg.archive_dir)
325}
326
327pub 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
335pub 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
348fn 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 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
378pub 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
396pub 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
411pub 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
452pub 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
471pub 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 let (env, _origin) = registered();
519
520 let ctx = create(&env, "origin", "main");
521
522 assert_eq!(current_branch(&ctx), "main");
523 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 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 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 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 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 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 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 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 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}