//! `worktree_add/list/remove/prune` without a git binary: the created layout //! is git's own, opens as a linked worktree, shares objects, and removal and //! pruning follow `git worktree` rules. use makepad_git::test_support::{tempdir, TempDir}; use makepad_git::*; use std::fs; use std::path::{Path, PathBuf}; fn sig() -> Signature { Signature { name: "Test".into(), email: "test@example.org".into(), timestamp: 0, tz_offset: "+0000".into(), } } fn init_repo(dir: &Path) -> Repository { let git = dir.join(".git"); fs::create_dir_all(git.join("objects")).unwrap(); fs::create_dir_all(git.join("refs/heads")).unwrap(); fs::create_dir_all(git.join("refs/tags")).unwrap(); fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); Repository::open(dir).unwrap() } fn commit( repo: &mut Repository, files: &[(&str, &str)], parents: Vec, message: &str, ) -> (ObjectId, ObjectId) { let tree = write_nested_tree(repo, files); let commit = repo .write_commit(&Commit { tree, parents, author: sig(), committer: sig(), message: message.to_string(), }) .unwrap(); (commit, tree) } /// Write `files` as git does: "dir/inner.txt" becomes a `dir` subtree /// holding `inner.txt` (a tree entry name never contains '/'). fn write_nested_tree(repo: &mut Repository, files: &[(&str, &str)]) -> ObjectId { let mut entries = Vec::new(); let mut dirs: Vec<(&str, Vec<(&str, &str)>)> = Vec::new(); for (path, content) in files { match path.split_once('/') { Some((dir, rest)) => match dirs.iter_mut().find(|(name, _)| *name == dir) { Some((_, children)) => children.push((rest, content)), None => dirs.push((dir, vec![(rest, content)])), }, None => entries.push(TreeEntry { mode: 0o100644, name: path.to_string(), oid: repo.write_blob(content.as_bytes()).unwrap(), }), } } for (dir, children) in dirs { let oid = write_nested_tree(repo, &children); entries.push(TreeEntry { mode: 0o040000, name: dir.to_string(), oid }); } entries.sort_by(|a, b| a.name.cmp(&b.name)); repo.write_tree(&Tree { entries }).unwrap() } /// `main` with one commit on `main`, plus the path where `wt` will go. fn fixture() -> (TempDir, PathBuf, PathBuf, ObjectId) { let base = tempdir().unwrap(); let main = base.path().join("main"); fs::create_dir_all(&main).unwrap(); let mut repo = init_repo(&main); let (first, _) = commit( &mut repo, &[("file.txt", "hello\n"), ("dir/inner.txt", "inner\n")], vec![], "initial\n", ); repo.create_branch("main", &first).unwrap(); let wt = base.path().join("wt"); (base, main, wt, first) } fn canon(path: &Path) -> PathBuf { path.canonicalize().unwrap() } #[test] fn add_new_branch_opens_as_linked_worktree_sharing_objects() { let (_base, main, wt, first) = fixture(); let mut repo = Repository::open(&main).unwrap(); let added = repo .worktree_add(&wt, WorktreeBranch::NewFrom { name: "feature", start: first }, None) .unwrap(); assert_eq!(added.name, "wt"); assert_eq!(added.branch.as_deref(), Some("feature")); assert_eq!(added.head, Some(first)); assert!(!added.main); // Layout is git's. let private = main.join(".git/worktrees/wt"); assert_eq!(canon(&added.git_dir), canon(&private)); assert_eq!(fs::read_to_string(private.join("HEAD")).unwrap(), "ref: refs/heads/feature\n"); assert_eq!(fs::read_to_string(private.join("commondir")).unwrap(), "../..\n"); assert_eq!( fs::read_to_string(private.join("gitdir")).unwrap().trim_end(), format!("{}", canon(&wt).join(".git").display()) ); assert!(private.join("index").is_file()); assert!(fs::read_to_string(wt.join(".git")).unwrap().starts_with("gitdir: ")); assert_eq!(fs::read_to_string(wt.join("file.txt")).unwrap(), "hello\n"); assert_eq!(fs::read_to_string(wt.join("dir/inner.txt")).unwrap(), "inner\n"); // Path resolution helpers accept it. let paths = repository_paths(&wt).unwrap().unwrap(); assert_eq!(canon(&paths.git_dir), canon(&private)); assert_eq!(canon(&paths.common_dir), canon(&main.join(".git"))); assert_eq!(canon(&resolve_gitfile(&wt.join(".git")).unwrap()), canon(&private)); assert_eq!(canon(&resolve_commondir(&private).unwrap()), canon(&main.join(".git"))); // Opens on its own path with its own HEAD and the shared object store. let mut linked = Repository::open(&wt).unwrap(); assert_eq!(linked.current_branch().unwrap().as_deref(), Some("feature")); assert_eq!(linked.head_oid().unwrap(), first); assert_eq!(linked.read_commit(&first).unwrap().message, "initial\n"); assert!(linked.status().unwrap().entries.is_empty(), "fresh checkout is clean"); let (second, _) = commit(&mut linked, &[("file.txt", "changed\n")], vec![first], "second\n"); assert_eq!(repo.read_commit(&second).unwrap().message, "second\n"); // The main worktree's HEAD is untouched and the new branch exists there. assert_eq!(repo.current_branch().unwrap().as_deref(), Some("main")); assert_eq!(repo.resolve_ref("refs/heads/feature").unwrap(), first); } #[test] fn list_shows_main_and_linked_with_flags() { let (_base, main, wt, first) = fixture(); let mut repo = Repository::open(&main).unwrap(); repo.worktree_add(&wt, WorktreeBranch::NewFrom { name: "feature", start: first }, None) .unwrap(); let list = repo.worktree_list().unwrap(); assert_eq!(list.len(), 2); assert!(list[0].main); assert_eq!(list[0].name, ""); assert_eq!(canon(&list[0].path), canon(&main)); assert_eq!(list[0].branch.as_deref(), Some("main")); assert_eq!(list[0].head, Some(first)); assert_eq!(list[1].name, "wt"); assert_eq!(canon(&list[1].path), canon(&wt)); assert_eq!(list[1].branch.as_deref(), Some("feature")); assert_eq!(list[1].head, Some(first)); assert_eq!(list[1].locked, None); assert_eq!(list[1].prunable, None); // Locking is reported and suppresses prunable; a detached HEAD has no branch. fs::write(main.join(".git/worktrees/wt/locked"), "for tests\n").unwrap(); fs::write(main.join(".git/worktrees/wt/HEAD"), format!("{}\n", first.to_hex())).unwrap(); let list = repo.worktree_list().unwrap(); assert_eq!(list[1].locked.as_deref(), Some("for tests")); assert_eq!(list[1].branch, None); assert_eq!(list[1].head, Some(first)); // The same list is visible from the linked worktree. let linked = Repository::open(&wt).unwrap(); assert_eq!(linked.worktree_list().unwrap().len(), 2); } #[test] fn checked_out_branch_is_refused_and_names_deduplicate() { let (base, main, wt, first) = fixture(); let mut repo = Repository::open(&main).unwrap(); repo.worktree_add(&wt, WorktreeBranch::NewFrom { name: "feature", start: first }, None) .unwrap(); let other = base.path().join("other"); let error = repo .worktree_add(&other, WorktreeBranch::Existing("feature"), None) .unwrap_err(); assert!(error.to_string().contains("already checked out"), "{error}"); assert!(!other.exists(), "nothing is created for a refused add"); assert!(!main.join(".git/worktrees/other").exists()); let error = repo .worktree_add(&other, WorktreeBranch::Existing("main"), None) .unwrap_err(); assert!(error.to_string().contains("already checked out"), "{error}"); let error = repo .worktree_add(&other, WorktreeBranch::NewFrom { name: "feature", start: first }, None) .unwrap_err(); assert!(error.to_string().contains("already exists"), "{error}"); // A second registration for the same file name gets a numeric suffix. let nested = base.path().join("elsewhere").join("wt"); let added = repo .worktree_add(&nested, WorktreeBranch::NewFrom { name: "second", start: first }, None) .unwrap(); assert_eq!(added.name, "wt1"); assert!(main.join(".git/worktrees/wt1/HEAD").is_file()); // Explicit names and a non-empty destination. let named = base.path().join("named"); fs::create_dir_all(&named).unwrap(); fs::write(named.join("stale"), "x").unwrap(); let error = repo .worktree_add(&named, WorktreeBranch::NewFrom { name: "third", start: first }, Some("third")) .unwrap_err(); assert!(error.to_string().contains("not empty"), "{error}"); fs::remove_file(named.join("stale")).unwrap(); let added = repo .worktree_add(&named, WorktreeBranch::NewFrom { name: "third", start: first }, Some("third")) .unwrap(); assert_eq!(added.name, "third"); assert_eq!(repo.worktree_list().unwrap().len(), 4); } #[test] fn remove_refuses_dirty_without_force_and_keeps_the_branch() { let (_base, main, wt, first) = fixture(); let mut repo = Repository::open(&main).unwrap(); repo.worktree_add(&wt, WorktreeBranch::NewFrom { name: "feature", start: first }, None) .unwrap(); fs::write(wt.join("file.txt"), "edited\n").unwrap(); let error = repo.worktree_remove("wt", false).unwrap_err(); assert!(error.to_string().contains("modified or untracked"), "{error}"); assert!(wt.join("file.txt").is_file(), "dirty work is preserved"); assert!(main.join(".git/worktrees/wt").is_dir()); repo.worktree_remove(wt.to_str().unwrap(), true).unwrap(); assert!(!wt.exists()); assert!(!main.join(".git/worktrees/wt").exists()); assert_eq!(repo.resolve_ref("refs/heads/feature").unwrap(), first, "branch is never touched"); assert_eq!(repo.worktree_list().unwrap().len(), 1); assert!(repo.worktree_remove("wt", true).is_err(), "unknown worktree"); assert!(repo.worktree_remove("", true).is_err(), "main is never removed"); // A clean worktree removes without force; an untracked file counts as dirty. let wt2 = _base.path().join("wt2"); repo.worktree_add(&wt2, WorktreeBranch::Existing("feature"), None).unwrap(); fs::write(wt2.join("scratch.txt"), "x\n").unwrap(); assert!(repo.worktree_remove("wt2", false).is_err()); fs::remove_file(wt2.join("scratch.txt")).unwrap(); repo.worktree_remove("wt2", false).unwrap(); assert!(!wt2.exists()); } #[test] fn prune_drops_registrations_whose_checkout_vanished() { let (base, main, wt, first) = fixture(); let mut repo = Repository::open(&main).unwrap(); repo.worktree_add(&wt, WorktreeBranch::NewFrom { name: "feature", start: first }, None) .unwrap(); let keep = base.path().join("keep"); repo.worktree_add(&keep, WorktreeBranch::NewFrom { name: "keep", start: first }, None) .unwrap(); assert!(repo.worktree_prune().unwrap().is_empty()); fs::remove_dir_all(&wt).unwrap(); let list = repo.worktree_list().unwrap(); let gone = list.iter().find(|e| e.name == "wt").unwrap(); assert!(gone.prunable.as_deref().unwrap().contains("non-existent"), "{:?}", gone.prunable); assert_eq!(list.iter().find(|e| e.name == "keep").unwrap().prunable, None); assert_eq!(repo.worktree_prune().unwrap(), vec!["wt".to_string()]); assert!(!main.join(".git/worktrees/wt").exists()); assert!(main.join(".git/worktrees/keep").is_dir()); assert_eq!(repo.resolve_ref("refs/heads/feature").unwrap(), first); // A locked registration with a missing checkout is not pruned. fs::remove_dir_all(&keep).unwrap(); fs::write(main.join(".git/worktrees/keep/locked"), "").unwrap(); assert!(repo.worktree_prune().unwrap().is_empty()); assert!(main.join(".git/worktrees/keep").is_dir()); } #[test] fn update_worktree_rewrites_only_changed_paths_and_refuses_local_edits() { let (_base, main, wt, first) = fixture(); let mut repo = Repository::open(&main).unwrap(); repo.worktree_add(&wt, WorktreeBranch::NewFrom { name: "feature", start: first }, None) .unwrap(); let old_tree = repo.read_commit(&first).unwrap().tree; let (second, new_tree) = commit( &mut repo, &[("file.txt", "hello\n"), ("new.txt", "new\n")], vec![first], "second\n", ); let mut linked = Repository::open(&wt).unwrap(); let before = fs::metadata(wt.join("file.txt")).unwrap().modified().unwrap(); linked.update_worktree(&old_tree, &new_tree).unwrap(); assert_eq!(fs::read_to_string(wt.join("new.txt")).unwrap(), "new\n"); assert!(!wt.join("dir").exists(), "removed file and its empty directory are gone"); assert_eq!(fs::metadata(wt.join("file.txt")).unwrap().modified().unwrap(), before); linked.create_branch("feature", &second).unwrap(); assert!(linked.status().unwrap().entries.is_empty(), "index follows the update"); // A locally edited file is never overwritten. let (_, third_tree) = commit(&mut repo, &[("file.txt", "v3\n"), ("new.txt", "new\n")], vec![second], "third\n"); fs::write(wt.join("file.txt"), "my edit\n").unwrap(); let error = linked.update_worktree(&new_tree, &third_tree).unwrap_err(); assert!(error.to_string().contains("modified locally"), "{error}"); assert_eq!(fs::read_to_string(wt.join("file.txt")).unwrap(), "my edit\n"); }