304 lines
13 KiB
Rust
304 lines
13 KiB
Rust
//! `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<ObjectId>,
|
|
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");
|
|
}
|