wsb 0.251.6311

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Worktrees of a project with nested submodules, made, removed and
//! migrated against real repositories: what is being tested is which git
//! repository each checkout is a worktree of, and a stand-in would agree with
//! any answer.

use super::*;

fn run(dir: &Path, args: &[&str]) -> String {
    let output = crate::git_backend::git_command()
        .arg("-C")
        .arg(dir)
        .args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "protocol.file.allow=always"])
        .args(args)
        .output()
        .expect("git runs");
    assert!(
        output.status.success(),
        "git {args:?} in {}: {}",
        dir.display(),
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}

fn commit_file(dir: &Path, name: &str, text: &str) -> String {
    std::fs::write(dir.join(name), text).expect("a file");
    run(dir, &["add", name]);
    run(dir, &["commit", "--quiet", "-m", name]);
    run(dir, &["rev-parse", "HEAD"])
}

fn origin(dir: &Path) {
    std::fs::create_dir_all(dir).expect("mkdir");
    run(dir, &["init", "--quiet", "-b", "main"]);
    commit_file(dir, "README.md", "readme\n");
}

/// `$PRJ` with project `alpha`: `top`, holding submodule `mid`, which holds
/// submodule `deps/leaf` — cloned, recursively, as `alpha/main-worktree`.
struct Layout {
    _scratch: tempfile::TempDir,
    origins: PathBuf,
    prj: wsb_prj::Prj,
}

impl Layout {
    fn new() -> Self {
        let scratch = tempfile::tempdir().expect("a scratch directory");
        let base = crate::platform::canonicalize(scratch.path()).expect("resolves");
        let origins = base.join("origins");
        for name in ["leaf", "mid", "top"] {
            origin(&origins.join(name));
        }
        let leaf = origins.join("leaf");
        let mid = origins.join("mid");
        run(&mid, &["submodule", "--quiet", "add", leaf.to_str().unwrap(), "deps/leaf"]);
        run(&mid, &["commit", "--quiet", "-m", "leaf"]);
        let top = origins.join("top");
        run(&top, &["submodule", "--quiet", "add", mid.to_str().unwrap(), "mid"]);
        run(&top, &["commit", "--quiet", "-m", "mid"]);

        let root = base.join("prj");
        let project = root.join("alpha");
        std::fs::create_dir_all(&project).expect("mkdir");
        run(
            &project,
            &["clone", "--quiet", "--recurse-submodules", top.to_str().unwrap(), "main-worktree"],
        );
        let prj = wsb_prj::Prj::at(&root).expect("a PRJ");
        Self {
            _scratch: scratch,
            origins,
            prj,
        }
    }

    fn target(&self, name: &str) -> Target {
        Target::parse(&self.prj, &format!("alpha/{name}")).expect("a target")
    }

    fn main(&self) -> PathBuf {
        self.prj.project("alpha").expect("alpha").main_worktree()
    }
}

/// TEST20037: a worktree's submodules are worktrees of main's repositories,
/// on the worktree's branch, locked.
///
/// Cloned instead, each worktree carried its own copy of every submodule —
/// a gigabyte apiece here — and a commit made in one worktree's submodule
/// could not be seen from main until it had been pushed and fetched.
#[test]
fn test20037_a_worktrees_submodules_share_mains_repositories() {
    let layout = Layout::new();
    let main = layout.main();
    let target = layout.target("x");
    let checkouts = add(&target, None, None).expect("made");
    let wt = target.path();

    let paths: Vec<&str> = checkouts.iter().map(|c| c.path.as_str()).collect();
    assert_eq!(paths, ["", "mid", "mid/deps/leaf"]);
    for checkout in &checkouts {
        assert_eq!(checkout.branch.as_deref(), Some("card/x"), "{checkout:?}");
    }

    for (here, there) in [
        (wt.clone(), main.clone()),
        (wt.join("mid"), main.join("mid")),
        (wt.join("mid/deps/leaf"), main.join("mid/deps/leaf")),
    ] {
        assert_eq!(
            common_dir(&here).unwrap(),
            common_dir(&there).unwrap(),
            "{} is not a worktree of {}",
            here.display(),
            there.display()
        );
        let registered = registrations(&there).unwrap();
        let mine = registered
            .iter()
            .find(|registration| same_path(&registration.path, &here))
            .expect("registered");
        assert!(mine.locked, "{} is not locked", here.display());
    }
    assert!(
        !wt.join(".git").is_dir() && !common_dir(&wt).unwrap().join("worktrees/x-worktree/modules").exists(),
        "no submodule was cloned into the worktree's git directory"
    );
    assert_eq!(run(&wt, &["status", "--porcelain"]), "", "the superproject is clean");

    // A commit in the worktree's submodule is in main's repository at once.
    let made = commit_file(&wt.join("mid/deps/leaf"), "new.txt", "new\n");
    assert!(has_commit(&main.join("mid/deps/leaf"), &made).unwrap());

    // An existing branch is used, and a second worktree gets its own.
    run(&main.join("mid"), &["branch", "card/y"]);
    let second = add(&layout.target("y"), None, None).expect("made");
    assert_eq!(second[1].branch.as_deref(), Some("card/y"));
}

/// TEST20038: removing a worktree refuses to lose work, and takes nothing
/// else with it.
///
/// Uncommitted changes, untracked files and commits on no remote branch, in
/// any repository however deep, refuse the removal before anything is
/// touched. Removal takes this worktree's registrations and no other's: a
/// global prune once dropped the registrations of a worktree whose folder was
/// briefly elsewhere, and its submodules stopped being repositories.
#[test]
fn test20038_removal_refuses_unsaved_work_and_prunes_nothing_else() {
    let layout = Layout::new();
    let main = layout.main();
    let target = layout.target("x");
    add(&target, None, None).expect("made");
    let wt = target.path();
    let leaf = wt.join("mid/deps/leaf");

    // Another worktree whose folder is missing right now, and an unlocked
    // registration someone made by hand whose folder is missing too: a
    // prune would drop the second (it leaves locked ones), so the second is
    // what shows whether removal pruned.
    let away = layout.target("away");
    add(&away, None, None).expect("made");
    let moved = layout.origins.join("away-moved-aside");
    std::fs::rename(away.path(), &moved).expect("moved aside");
    let by_hand = layout.origins.join("by-hand");
    run(&main.join("mid"), &["worktree", "add", "--quiet", "--detach", by_hand.to_str().unwrap()]);
    let by_hand_moved = layout.origins.join("by-hand-moved-aside");
    std::fs::rename(&by_hand, &by_hand_moved).expect("moved aside");

    std::fs::write(leaf.join("scratch.txt"), "untracked\n").expect("a file");
    let refusal = remove(&target, false).expect_err("an untracked file refuses");
    assert!(refusal.contains("mid/deps/leaf") && refusal.contains("untracked"), "{refusal}");
    assert!(wt.is_dir(), "nothing was removed");
    std::fs::remove_file(leaf.join("scratch.txt")).expect("rm");

    commit_file(&leaf, "local.txt", "only here\n");
    let refusal = remove(&target, false).expect_err("an unpushed commit refuses");
    assert!(refusal.contains("on no remote branch"), "{refusal}");

    run(&leaf, &["push", "--quiet", "--set-upstream", "origin", "card/x"]);
    remove(&target, true).expect("removed");
    assert!(!wt.exists());
    for repo in [main.clone(), main.join("mid"), main.join("mid/deps/leaf")] {
        assert!(
            !registrations(&repo).unwrap().iter().any(|r| r.path.ends_with("x-worktree") || r.path.to_string_lossy().contains("x-worktree/")),
            "{} still registers the removed worktree",
            repo.display()
        );
        assert!(
            registrations(&repo).unwrap().iter().any(|r| r.path.to_string_lossy().contains("away-worktree")),
            "{} lost the other worktree's registration",
            repo.display()
        );
    }
    assert!(
        registrations(&main.join("mid")).unwrap().iter().any(|r| r.path.ends_with("by-hand")),
        "an unlocked registration somebody else made was pruned"
    );
    assert!(!has_local_branch(&main, "card/x").unwrap(), "--delete-branch deleted the branch");
    std::fs::rename(&moved, away.path()).expect("moved back");
    assert_eq!(run(&away.path().join("mid"), &["rev-parse", "--is-inside-work-tree"]), "true");
}

/// TEST20039: an older worktree's cloned submodules become shared ones, and
/// no commit only a clone had is lost.
///
/// The worktrees made before this module had a clone per submodule. Each
/// clone's branches and HEAD are fetched into main's repository under
/// `refs/salvage/` first; the checkout is then put back as a worktree of
/// main's repository on the same commit and branch, and the clone is gone.
#[test]
fn test20039_cloned_submodules_are_migrated_without_losing_a_commit() {
    let layout = Layout::new();
    let main = layout.main();
    let target = layout.target("old");
    let wt = target.path();
    run(&main, &["worktree", "add", "--quiet", "-b", "card/old", wt.to_str().unwrap()]);
    run(&wt, &["submodule", "--quiet", "update", "--init", "--recursive"]);
    let leaf = wt.join("mid/deps/leaf");
    run(&leaf, &["checkout", "--quiet", "-b", "feature"]);
    let only_in_clone = commit_file(&leaf, "clone-only.txt", "x\n");
    assert_ne!(common_dir(&leaf).unwrap(), common_dir(&main.join("mid/deps/leaf")).unwrap());
    let before = list(&layout.prj.project("alpha").unwrap()).unwrap();
    let old = before.iter().find(|w| w.name == "old").unwrap();
    assert_eq!(old.cloned, ["mid", "mid/deps/leaf"]);

    // Dirty clones stop it before anything moves.
    std::fs::write(wt.join("mid/stray.txt"), "x\n").expect("a file");
    assert!(migrate(&target).expect_err("dirty").contains("mid"));
    std::fs::remove_file(wt.join("mid/stray.txt")).expect("rm");

    let placed = migrate(&target).expect("migrated");
    assert_eq!(placed.iter().map(|c| c.path.as_str()).collect::<Vec<_>>(), ["mid", "mid/deps/leaf"]);
    let main_leaf = main.join("mid/deps/leaf");
    assert_eq!(common_dir(&leaf).unwrap(), common_dir(&main_leaf).unwrap(), "now a worktree of main's");
    assert_eq!(run(&leaf, &["rev-parse", "HEAD"]), only_in_clone, "on the same commit");
    assert_eq!(branch_of(&leaf).unwrap().as_deref(), Some("feature"), "on the same branch");
    assert_eq!(
        run(&main_leaf, &["rev-parse", "refs/salvage/old/mid/deps/leaf/heads/feature"]),
        only_in_clone,
        "the clone's branch is kept in main's repository"
    );
    assert!(
        !common_dir(&wt).unwrap().join("worktrees/old-worktree/modules").exists(),
        "the clones' git directories are gone"
    );
    let after = list(&layout.prj.project("alpha").unwrap()).unwrap();
    let old = after.iter().find(|w| w.name == "old").unwrap();
    assert!(old.cloned.is_empty(), "{old:?}");
    assert_eq!(old.shared, ["mid", "mid/deps/leaf"]);
    assert!(migrate(&target).expect("again").is_empty(), "a migrated worktree has nothing left to migrate");
}

/// TEST20040: a worktree that cannot be made whole is not made at all.
///
/// A submodule main has not checked out cannot share main's repository. The
/// superproject worktree made before that is found is removed again, with its
/// branch, rather than left as a worktree with a hole in it.
#[test]
fn test20040_a_failed_add_leaves_nothing_behind() {
    let layout = Layout::new();
    let main = layout.main();
    run(&main, &["submodule", "--quiet", "deinit", "--force", "--all"]);
    let target = layout.target("x");
    let error = add(&target, None, None).expect_err("mid is not checked out in main");
    assert!(error.contains("mid") && error.contains("nothing of alpha/x was left behind"), "{error}");
    assert!(!target.path().exists());
    assert!(!has_local_branch(&main, "card/x").unwrap(), "the branch made for it is gone");
    assert_eq!(registrations(&main).unwrap().len(), 1, "only main is registered");

    assert!(Target::parse(&layout.prj, "alpha/main").is_err(), "main is not made by add");
    assert!(Target::parse(&layout.prj, "alpha").is_err());
    assert!(Target::parse(&layout.prj, "nope/x").is_err());
}

/// TEST20046: a checkout whose registration was pruned is reported, kept from
/// removal, and repaired without losing what it held.
///
/// A global `git worktree prune` once dropped the registrations of a
/// worktree's submodules: their `.git` named repositories that were gone, and
/// every git command in the worktree failed. Listing it said nothing useful,
/// and nothing could put it right. The repair moves the orphaned checkout to
/// the project's state folder, puts a worktree of main's repository back on
/// the commit the superproject pins, and drops the orphan only when nothing
/// in it differs.
#[test]
fn test20046_a_pruned_checkout_is_repaired_without_losing_work() {
    let layout = Layout::new();
    let main = layout.main();
    let project = layout.prj.project("alpha").unwrap();

    for (name, edit) in [("clean", false), ("edited", true)] {
        let target = layout.target(name);
        add(&target, None, None).expect("made");
        let wt = target.path();
        let mid = wt.join("mid");
        if edit {
            std::fs::write(mid.join("README.md"), "an edit nobody committed\n").expect("write");
        }
        // What a prune does: the registration goes, the checkout stays.
        let registration = git_dir(&mid).expect("mid is a worktree");
        std::fs::remove_dir_all(&registration).expect("pruned");

        let listed = list(&project).unwrap();
        let entry = listed.iter().find(|w| w.name == name).unwrap();
        assert_eq!(entry.broken, ["mid"], "{entry:?}");
        assert!(remove(&target, false).expect_err("refused").contains("migrate"), "rm points at the repair");

        let placed = migrate(&target).expect("repaired");
        assert!(placed.iter().any(|c| c.path == "mid"), "{placed:?}");
        assert_eq!(common_dir(&mid).unwrap(), common_dir(&main.join("mid")).unwrap());
        assert_eq!(
            run(&wt, &["ls-tree", "HEAD", "mid"]).split_whitespace().nth(2).unwrap(),
            run(&mid, &["rev-parse", "HEAD"]),
            "put back on the commit the superproject pins"
        );
        assert_eq!(run(&mid.join("deps/leaf"), &["rev-parse", "--is-inside-work-tree"]), "true");

        let aside = project.state_dir().join("orphaned").join(name);
        if edit {
            let kept: Vec<PathBuf> = std::fs::read_dir(&aside)
                .expect("the edited checkout is kept")
                .map(|e| e.unwrap().path().join("mid/README.md"))
                .collect();
            assert_eq!(kept.len(), 1);
            assert_eq!(std::fs::read_to_string(&kept[0]).unwrap(), "an edit nobody committed\n");
        } else {
            assert!(!aside.exists(), "an orphan holding nothing new is not kept");
        }
    }
}