wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! What `discard` finds, and what it leaves alone.
//!
//! Against real repositories with real changes in them. The whole value of
//! this command is that the list it shows is the truth about what is about to
//! be destroyed, and a fixture that faked git's status would test the report
//! rather than the finding.

use super::*;

/// A repository with a commit in it, so HEAD exists to reset to.
fn repository(at: &Path) {
    std::fs::create_dir_all(at).expect("the directory is created");
    run_git(at, &["init", "--quiet"]);
    run_git(at, &["config", "user.email", "test@example.invalid"]);
    run_git(at, &["config", "user.name", "Test"]);
    // Line endings as written. A machine whose git converts them on checkout
    // (`core.autocrlf`, the Windows default) would hand these tests back
    // `committed\r\n` for `committed\n` — a fact about that machine's git,
    // not about what discard restored.
    run_git(at, &["config", "core.autocrlf", "false"]);
    std::fs::write(at.join("tracked.txt"), "committed\n").expect("the file is written");
    run_git(at, &["add", "tracked.txt"]);
    run_git(at, &["commit", "--quiet", "-m", "first"]);
}

fn run_git(at: &Path, args: &[&str]) {
    let status = crate::git_backend::git_command()
        .args(args)
        .current_dir(at)
        .status()
        .expect("git runs");
    assert!(status.success(), "git {args:?} failed in {}", at.display());
}

fn options() -> Options {
    Options {
        untracked: false,
        assume_yes: true,
        dry_run: false,
    }
}

/// TEST9890: a clean worktree is not offered for discarding.
#[test]
fn test9890_a_clean_worktree_is_not_dirty() {
    let dir = tempfile::tempdir().expect("a scratch directory");
    let root = crate::platform::canonicalize(&dir.path()).expect("resolves");
    repository(&root);

    let dirty = find_dirty(&root).expect("the walk succeeds");
    assert!(
        dirty.is_empty(),
        "a repository with nothing changed has nothing to discard"
    );
}

/// TEST9891: staged, unstaged and deleted are all local changes.
///
/// All three are thrown away by the same `reset --hard`, and all three have to
/// appear in the list — an operator approving the discard is approving these.
#[test]
fn test9891_every_kind_of_tracked_change_is_found() {
    let dir = tempfile::tempdir().expect("a scratch directory");
    let root = crate::platform::canonicalize(&dir.path()).expect("resolves");
    repository(&root);

    // A file that exists to be deleted, committed FIRST and on its own — a
    // commit made after staging something else sweeps that up too, which is
    // what made this fixture stage nothing.
    std::fs::write(root.join("gone.txt"), "doomed\n").expect("written");
    run_git(&root, &["add", "gone.txt"]);
    run_git(&root, &["commit", "--quiet", "-m", "second"]);
    std::fs::remove_file(root.join("gone.txt")).expect("removed");

    // An edit to a committed file, and an addition staged but not committed.
    std::fs::write(root.join("tracked.txt"), "edited\n").expect("written");
    std::fs::write(root.join("staged.txt"), "new\n").expect("written");
    run_git(&root, &["add", "staged.txt"]);

    let dirty = find_dirty(&root).expect("the walk succeeds");
    assert_eq!(dirty.len(), 1, "one worktree is dirty");
    let mut found = dirty[0].tracked.clone();
    found.sort();
    assert_eq!(
        found,
        vec!["gone.txt", "staged.txt", "tracked.txt"],
        "an edit, a staged addition and a deletion are all local changes"
    );
}

/// TEST9892: untracked files are found, and are not touched by default.
///
/// git holds no copy of them, so deleting one is unrecoverable in a way that
/// discarding an edit to a committed file is not. They are shown, and they
/// stay unless the operator asks for them by name.
#[test]
fn test9892_untracked_files_are_kept_unless_asked_for() {
    let dir = tempfile::tempdir().expect("a scratch directory");
    let root = crate::platform::canonicalize(&dir.path()).expect("resolves");
    repository(&root);

    std::fs::write(root.join("tracked.txt"), "edited\n").expect("written");
    std::fs::write(root.join("scratch.txt"), "never committed\n").expect("written");

    let dirty = find_dirty(&root).expect("the walk succeeds");
    assert_eq!(dirty[0].untracked, vec!["scratch.txt"]);

    discard(&dirty, &options()).expect("the discard succeeds");

    assert_eq!(
        std::fs::read_to_string(root.join("tracked.txt")).expect("readable"),
        "committed\n",
        "the tracked edit went back to what HEAD holds"
    );
    assert!(
        root.join("scratch.txt").exists(),
        "an untracked file must survive a discard that was not told to delete it"
    );
}

/// TEST9893: --untracked deletes them.
#[test]
fn test9893_untracked_files_go_when_asked_for() {
    let dir = tempfile::tempdir().expect("a scratch directory");
    let root = crate::platform::canonicalize(&dir.path()).expect("resolves");
    repository(&root);

    std::fs::write(root.join("scratch.txt"), "never committed\n").expect("written");
    std::fs::create_dir(root.join("scratchdir")).expect("created");
    std::fs::write(root.join("scratchdir/inside.txt"), "also new\n").expect("written");

    let dirty = find_dirty(&root).expect("the walk succeeds");
    discard(
        &dirty,
        &Options {
            untracked: true,
            ..options()
        },
    )
    .expect("the discard succeeds");

    assert!(!root.join("scratch.txt").exists(), "the untracked file went");
    assert!(
        !root.join("scratchdir").exists(),
        "and the directory that held only untracked files went with it"
    );
}

/// TEST9894: ignored files are never touched.
///
/// Build output, `node_modules`, and `.wsb/` — which holds the secret store —
/// are all ignored. A command about local changes that emptied those would
/// destroy this machine's credentials.
#[test]
fn test9894_ignored_files_are_left_alone() {
    let dir = tempfile::tempdir().expect("a scratch directory");
    let root = crate::platform::canonicalize(&dir.path()).expect("resolves");
    repository(&root);

    std::fs::write(root.join(".gitignore"), "/.wsb/\n/target/\n").expect("written");
    run_git(&root, &["add", ".gitignore"]);
    run_git(&root, &["commit", "--quiet", "-m", "ignore"]);
    std::fs::create_dir(root.join(".wsb")).expect("created");
    std::fs::write(root.join(".wsb/secrets.wsbs"), "not really\n").expect("written");
    std::fs::write(root.join("tracked.txt"), "edited\n").expect("written");

    let dirty = find_dirty(&root).expect("the walk succeeds");
    assert!(
        !dirty[0]
            .untracked
            .iter()
            .any(|path| path.contains(".wsb")),
        "an ignored path is not a local change: {:?}",
        dirty[0].untracked
    );

    discard(
        &dirty,
        &Options {
            untracked: true,
            ..options()
        },
    )
    .expect("the discard succeeds");

    assert!(
        root.join(".wsb/secrets.wsbs").exists(),
        "the secret store must survive `wsb discard --untracked`"
    );
}

/// TEST9895: every worktree in the fleet is looked at, not just the top one.
///
/// This is the command's reason to exist. A fleet is twenty-odd repositories,
/// and the change somebody forgot is never in the one they are standing in.
#[test]
fn test9895_dirty_submodules_are_found_too() {
    let dir = tempfile::tempdir().expect("a scratch directory");
    let root = crate::platform::canonicalize(&dir.path()).expect("resolves");
    let inner = root.join("child");
    repository(&root);
    repository(&inner);

    // A real submodule, so the walk crosses the boundary the way it does in
    // the worktree rather than merely finding a nested directory.
    run_git(
        &root,
        &[
            "-c",
            "protocol.file.allow=always",
            "submodule",
            "add",
            "--quiet",
            "./child",
            "child",
        ],
    );
    run_git(&root, &["commit", "--quiet", "-m", "add submodule"]);

    std::fs::write(inner.join("tracked.txt"), "edited in the submodule\n").expect("written");

    let dirty = find_dirty(&root).expect("the walk succeeds");
    let child = dirty
        .iter()
        .find(|worktree| worktree.label == "child")
        .expect("the submodule's own changes were not found");
    assert_eq!(child.tracked, vec!["tracked.txt"]);

    discard(&dirty, &options()).expect("the discard succeeds");
    assert_eq!(
        std::fs::read_to_string(inner.join("tracked.txt")).expect("readable"),
        "committed\n",
        "the submodule's worktree was restored too"
    );
}

/// TEST9896: a submodule moved to another commit is reported as what it is.
///
/// `reset --hard` in the parent does not move a submodule's working tree back,
/// so counting it among the files that were restored would be a lie about what
/// the command did. It is named separately, with the command that does restore
/// it.
#[test]
fn test9896_a_moved_submodule_is_not_reported_as_a_discarded_file() {
    let dir = tempfile::tempdir().expect("a scratch directory");
    let root = crate::platform::canonicalize(&dir.path()).expect("resolves");
    let inner = root.join("child");
    repository(&root);
    repository(&inner);

    run_git(
        &root,
        &[
            "-c",
            "protocol.file.allow=always",
            "submodule",
            "add",
            "--quiet",
            "./child",
            "child",
        ],
    );
    run_git(&root, &["commit", "--quiet", "-m", "add submodule"]);

    // Move the submodule on: a new commit inside it leaves the parent's
    // recorded gitlink pointing at the old one.
    std::fs::write(inner.join("tracked.txt"), "moved on\n").expect("written");
    run_git(&inner, &["commit", "--quiet", "-am", "second"]);

    let dirty = find_dirty(&root).expect("the walk succeeds");
    let parent = dirty
        .iter()
        .find(|worktree| worktree.label == ".")
        .expect("the parent has a moved submodule to report");
    assert_eq!(
        parent.moved_submodules,
        vec!["child"],
        "a moved submodule is its own kind of change"
    );
    assert!(
        parent.tracked.is_empty(),
        "and it must not be listed among the files a reset would restore: {:?}",
        parent.tracked
    );
}