wsb 0.253.6349

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! The whole point is not losing somebody's work. Every case here is one where
//! a cruder undo — `git checkout`, or re-rendering — would eat an edit.

use super::*;

/// `head`, `rendered`, `worktree` → the text that should be written back.
fn discard(head: &str, rendered: &str, worktree: &str) -> Discarded {
    discard_render(head, rendered, worktree).expect("merges")
}

#[test]
fn test11134_an_untouched_render_goes_back_whole() {
    // Nobody edited the file after the render, so all of it is the render's
    // and all of it goes back.
    let head = "version = \"1.0.0\"\nname = \"thing\"\n";
    let rendered = "version = \"1.1.0\"\nname = \"thing\"\n";
    let result = discard(head, rendered, rendered);
    assert_eq!(result.content, head);
    assert_eq!(result.kept, 0);
    assert!(!result.is_noop());
}

#[test]
fn test11135_an_edit_beside_the_render_survives_it() {
    // The case `git checkout` gets wrong: it would take the rename with it.
    let head = "version = \"1.0.0\"\nname = \"thing\"\n";
    let rendered = "version = \"1.1.0\"\nname = \"thing\"\n";
    let worktree = "version = \"1.1.0\"\nname = \"renamed\"\n";

    let result = discard(head, rendered, worktree);
    assert_eq!(
        result.content, "version = \"1.0.0\"\nname = \"renamed\"\n",
        "the version goes back, the rename stays"
    );
    assert_eq!(result.reverted, 1);
    assert_eq!(result.kept, 0);
}

#[test]
fn test11136_an_edit_on_the_rendered_line_itself_is_kept_and_reported() {
    // Both touched the same line. There is no answer that is certainly right,
    // so nothing is guessed — the person's line stands and the file is
    // reported as one this could not fully undo.
    let head = "version = \"1.0.0\"\n";
    let rendered = "version = \"1.1.0\"\n";
    let worktree = "version = \"9.9.9\"\n";

    let result = discard(head, rendered, worktree);
    assert_eq!(result.content, worktree, "their line is untouched");
    assert_eq!(result.kept, 1, "and it is reported rather than silently kept");
}

#[test]
fn test11137_a_render_that_contributes_nothing_changes_nothing() {
    // The renderer produces exactly what is committed. It has contributed
    // nothing, so there is nothing of its to take back — and a person's edits
    // must not be disturbed on the way to discovering that.
    let head = "stable\ncontent\n";
    let worktree = "stable\nmine\n";
    let result = discard(head, head, worktree);
    assert_eq!(result.content, worktree);
    assert!(result.is_noop());
}

#[test]
fn test11138_lines_added_by_a_person_are_not_removed() {
    let head = "one\ntwo\n";
    let rendered = "one\nTWO\n";
    let worktree = "one\nTWO\nthree added by hand\n";

    let result = discard(head, rendered, worktree);
    assert_eq!(result.content, "one\ntwo\nthree added by hand\n");
}

#[test]
fn test11139_lines_the_render_added_are_removed_again() {
    let head = "one\n";
    let rendered = "one\ngenerated\n";
    let result = discard(head, rendered, rendered);
    assert_eq!(result.content, "one\n");
}

#[test]
fn test11140_two_separate_regions_are_decided_separately() {
    // One region is the render's alone and goes back; the other is a person's
    // alone and stays. Deciding the file as a whole would sacrifice one.
    let head = "a\nb\nc\nd\ne\n";
    let rendered = "a\nB\nc\nd\ne\n";
    let worktree = "a\nB\nc\nD\ne\n";

    let result = discard(head, rendered, worktree);
    assert_eq!(result.content, "a\nb\nc\nD\ne\n");
    assert_eq!(result.reverted, 1);
    assert_eq!(result.kept, 0);
}

#[test]
fn test11141_a_file_the_render_created_is_emptied_back_to_nothing() {
    // No committed version at all: everything in it is the render's.
    let result = discard("", "generated\n", "generated\n");
    assert_eq!(result.content, "");
    assert!(!result.is_noop());
}

#[test]
fn test11142_a_created_file_a_person_added_to_keeps_what_they_added() {
    let result = discard("", "generated\n", "generated\nmine\n");
    assert_eq!(result.content, "mine\n", "only the generated line goes");
}

#[test]
fn test11143_a_trailing_newline_the_render_added_is_taken_back() {
    // `str::lines` cannot see this difference, which is why the merge works on
    // lines that keep their terminators.
    let result = discard("a", "a\n", "a\n");
    assert_eq!(result.content, "a");
}

#[test]
fn test11144_a_whole_file_rewritten_by_the_render_goes_back_whole() {
    let head = "old\nfile\ncontents\n";
    let rendered = "completely\ndifferent\n";
    let result = discard(head, rendered, rendered);
    assert_eq!(result.content, head);
}

#[test]
fn test11145_an_empty_worktree_file_is_not_refilled_from_head() {
    // Somebody emptied the file deliberately. That is their edit, and the
    // render's contribution is gone with everything else — there is nothing
    // of the render's left in the file to take back.
    let head = "one\n";
    let rendered = "one\ntwo\n";
    let result = discard(head, rendered, "");
    assert_eq!(result.content, "", "their empty file stays empty");
}

/// Against a real repository and a real render, because the whole risk is that
/// somebody's uncommitted work is destroyed.
mod against_a_tree {
    use super::super::*;
    use std::fs;

    fn git(root: &Path, args: &[&str]) {
        let out = crate::git_backend::git_command()
            .arg("-C")
            .arg(root)
            .args(args)
            .output()
            .expect("git runs");
        assert!(
            out.status.success(),
            "git {args:?}: {}",
            String::from_utf8_lossy(&out.stderr)
        );
    }

    /// A workspace with one template whose output is committed.
    fn workspace() -> tempfile::TempDir {
        let dir = tempfile::TempDir::new().expect("temp dir");
        let root = dir.path();
        git(root, &["init", "-q"]);
        git(root, &["config", "user.email", "t@example.com"]);
        git(root, &["config", "user.name", "t"]);
        fs::write(root.join("version.txt"), "1.0.0\n").expect("version");
        fs::write(
            root.join("thing.toml.wstemplate"),
            "name = \"thing\"\nversion = \"{{ project.version }}\"\n",
        )
        .expect("template");
        fs::write(
            root.join("thing.toml"),
            "name = \"thing\"\nversion = \"0.0.1\"\n",
        )
        .expect("output");
        git(root, &["add", "-A"]);
        git(root, &["commit", "-qm", "base"]);
        dir
    }

    #[test]
    fn test11146_a_hand_edit_beside_a_rendered_line_survives_the_discard() {
        let dir = workspace();
        let output = dir.path().join("thing.toml");

        // What the update did, and what a person then did to the same file.
        fs::write(&output, "name = \"renamed by hand\"\nversion = \"9.9.9\"\n")
            .expect("both edits");

        let head = String::from_utf8_lossy(
            &crate::git_backend::git_command()
                .arg("-C")
                .arg(dir.path())
                .args(["show", "HEAD:thing.toml"])
                .output()
                .expect("git show")
                .stdout,
        )
        .into_owned();
        let rendered = "name = \"thing\"\nversion = \"9.9.9\"\n";

        let merged = discard_render(&head, rendered, "name = \"renamed by hand\"\nversion = \"9.9.9\"\n")
            .expect("merges");

        assert_eq!(
            merged.content,
            "name = \"renamed by hand\"\nversion = \"0.0.1\"\n",
            "the rendered version goes back; the hand-written name stays"
        );
    }

    #[test]
    fn test11147_head_is_read_from_the_repository_that_owns_the_file() {
        // This tree is submodules all the way down. Asking the workspace root
        // about a path inside one of them gets no answer, and a discard that
        // treated "no answer" as "not committed" would delete the file.
        let dir = workspace();
        let text = super::super::head_text(&dir.path().join("thing.toml"))
            .expect("the committed version is found");
        assert_eq!(text, "name = \"thing\"\nversion = \"0.0.1\"\n");

        assert!(
            super::super::head_text(&dir.path().join("never-committed.toml")).is_none(),
            "a file with no committed version says so"
        );
    }

    /// A workspace whose template switches on its declared local variable, rendered into
    /// its LOCAL form on disk — the state `wsb local on` leaves a tree in.
    fn locally_rendered_workspace() -> tempfile::TempDir {
        let dir = tempfile::TempDir::new().expect("temp dir");
        let root = dir.path();
        git(root, &["init", "-q"]);
        git(root, &["config", "user.email", "t@example.com"]);
        git(root, &["config", "user.name", "t"]);

        fs::create_dir_all(root.join(".wsb")).expect("state dir");
        fs::write(
            root.join(".wsb/state.json"),
            format!(
                r#"{{"version":1,"version_suffix":null,
                     "project_name":"thing","tools":{{}},
                     "wstemplate_entries":[{{"root":".","alias":"thing"}}],
                     "completion_hint_shown":false}}"#
            ),
        )
        .expect("state");
        // The variable that selects the local branch, as the workspace declares it.
        fs::write(
            root.join(".wsb/stamp.json"),
            r#"{"local": {"variable": "LOCAL_LIBS"}}"#,
        )
        .expect("stamp configuration");

        fs::write(root.join("version.txt"), "1.0.0\n").expect("version");
        fs::write(
            root.join("dep.toml.wstemplate"),
            "{%- if env.LOCAL_LIBS %}\ndep = { path = \"../dep\" }\n\
             {%- else %}\ndep = { tag = \"v1\" }\n{%- endif %}\n",
        )
        .expect("template");
        // What `wsb local on` would have written: the local branch.
        fs::write(root.join("dep.toml"), "\ndep = { path = \"../dep\" }\n").expect("output");

        git(root, &["add", "-A"]);
        git(root, &["commit", "-qm", "base"]);
        dir
    }

    /// TEST12579: a manifest taken back exactly as committed takes its lock
    /// back exactly as committed.
    ///
    /// The committed pair is the pair known to agree. Relocked instead, npm
    /// wrote a lock of its own making; floom-uikit's then differed from the
    /// committed one after every take-back, the checkpoint committed it, and
    /// the commit moved floom-uikit's version on every checkpoint.
    #[test]
    fn test12579_a_manifest_taken_back_takes_its_lock_back() {
        let dir = tempfile::TempDir::new().expect("temp dir");
        let root = dir.path();
        git(root, &["init", "-q"]);
        git(root, &["config", "user.email", "t@example.com"]);
        git(root, &["config", "user.name", "t"]);
        fs::create_dir_all(root.join(".wsb")).expect("state dir");
        fs::write(
            root.join(".wsb/state.json"),
            r#"{"version":1,"project_name":"thing","tools":{},"wstemplate_entries":[{"root":".","alias":"thing"}]}"#,
        )
        .expect("state");
        fs::write(root.join("version.txt"), "1.0.0\n").expect("version");
        fs::write(
            root.join("package.json.wstemplate"),
            "{\"name\":\"thing\",\"dependencies\":{\"dep\":\"{% if env.LOCAL_LIBS %}file:../dep{% else %}^1.0.0{% endif %}\"}}\n",
        )
        .expect("template");
        let published = "{\"name\":\"thing\",\"dependencies\":{\"dep\":\"^1.0.0\"}}\n";
        let committed_lock = "{\"name\":\"thing\",\"lockfileVersion\":3,\"packages\":{\"\":{\"name\":\"thing\",\"dependencies\":{\"dep\":\"^1.0.0\"}}}}\n";
        fs::write(root.join("package.json"), published).expect("manifest");
        fs::write(root.join("package-lock.json"), committed_lock).expect("lock");
        git(root, &["add", "-A"]);
        git(root, &["commit", "-qm", "base"]);

        // `wsb local on`: the local manifest, and the lock relocked to match it.
        fs::write(root.join("package.json"), "{\"name\":\"thing\",\"dependencies\":{\"dep\":\"file:../dep\"}}\n")
            .expect("local manifest");
        fs::write(
            root.join("package-lock.json"),
            "{\"name\":\"thing\",\"lockfileVersion\":3,\"packages\":{\"\":{\"name\":\"thing\",\"dependencies\":{\"dep\":\"file:../dep\"}}},\"relocked\":\"locally\"}\n",
        )
        .expect("local lock");

        let _local = crate::test_environment::Environment::set(&[("LOCAL_LIBS", "1")]);
        super::super::run(root, false).expect("taken back");

        assert_eq!(fs::read_to_string(root.join("package.json")).unwrap(), published);
        assert_eq!(
            fs::read_to_string(root.join("package-lock.json")).unwrap(),
            committed_lock,
            "the lock was not put back as committed"
        );
    }

    /// TEST11161: a file carrying the LOCAL render is the renderer's work, even
    /// though the classifier itself runs with `LOCAL_LIBS` unset.
    ///
    /// Templates read the environment of the process that renders them, and
    /// nothing on disk records which of the two states a tree is in. A
    /// classifier that produced only the ordinary render would emit the
    /// published-tag branch, match nothing in a tree that is `local`, and report
    /// every generated manifest in the fleet as somebody's uncommitted work —
    /// which is exactly the noise `wsb status` was drowning in.
    ///
    /// Asserted from the OUTSIDE, with the variable deliberately unset, because
    /// that is the condition the bug needs: run with it set, a single-render
    /// implementation passes.
    #[test]
    fn test11161_the_local_render_is_recognised_without_the_variable_set() {
        let dir = locally_rendered_workspace();
        std::env::remove_var("LOCAL_LIBS");

        let settled = super::super::rendered_as_written(dir.path(), &crate::state::RunCache::new())
            .expect("classifies");

        assert!(
            settled.contains(&dir.path().join("dep.toml")),
            "the file holds exactly what a local render writes, so the renderer \
             wrote it — not a person. Settled: {settled:#?}"
        );
        assert!(
            std::env::var_os("LOCAL_LIBS").is_none(),
            "a read-only query must not leave the variable set for whatever renders next"
        );
    }

    /// TEST11162: a file somebody edited is NOT settled, whichever way it is
    /// rendered.
    ///
    /// The other half of the contract. Accepting both renders widens what
    /// counts as the renderer's work, so this pins the edge: text that matches
    /// neither branch is a person's, and a status report must still show it.
    #[test]
    fn test11162_a_hand_edited_generated_file_is_still_reported() {
        let dir = locally_rendered_workspace();
        fs::write(
            dir.path().join("dep.toml"),
            "\ndep = { path = \"../somewhere-else\" }\n",
        )
        .expect("edit");

        let settled = super::super::rendered_as_written(dir.path(), &crate::state::RunCache::new())
            .expect("classifies");

        assert!(
            !settled.contains(&dir.path().join("dep.toml")),
            "this matches neither render, so it is somebody's edit and stays visible"
        );
    }

    #[test]
    fn test11148_a_dry_run_writes_nothing() {
        let dir = workspace();
        let output = dir.path().join("thing.toml");
        let before = "name = \"mine\"\nversion = \"2.0.0\"\n";
        fs::write(&output, before).expect("edit");

        // The workspace has no .wsb state, so planning refuses rather than
        // half-running — which is itself the guarantee under test: a discard
        // that cannot work out what an update writes must not write anything.
        let planned = run(dir.path(), true);
        assert!(
            planned.is_err(),
            "a directory that is not a workspace is refused"
        );
        assert_eq!(
            fs::read_to_string(&output).expect("read"),
            before,
            "and nothing was written"
        );
    }
}