git-stk 0.8.6

Git-native stacked branch workflow helper
Documentation
mod common;

use common::TestRepo;

/// main <- feature/a (adds foo.txt) <- feature/b (adds bar.txt). Each line is
/// owned by a distinct commit, so blame attribution is unambiguous.
fn stack() -> TestRepo {
    let repo = TestRepo::new();
    repo.stack().args(["new", "feature/a"]).assert().success();
    repo.commit_file("foo.txt", "alpha\n", "add foo");
    repo.stack().args(["new", "feature/b"]).assert().success();
    repo.commit_file("bar.txt", "beta\n", "add bar");
    repo
}

#[test]
fn absorb_dry_run_routes_hunks_to_owning_commits() {
    let repo = stack();
    let head = repo.git(["rev-parse", "HEAD"]);

    // Edit a line each commit introduced, two branches down and one down.
    repo.write("foo.txt", "alpha fixed\n");
    repo.write("bar.txt", "beta fixed\n");
    repo.git(["add", "foo.txt", "bar.txt"]);

    repo.stack()
        .args(["absorb", "--dry-run"])
        .assert()
        .success()
        .stdout(predicates::str::contains("foo.txt:1 -> feature/a"))
        .stdout(predicates::str::contains("add foo"))
        .stdout(predicates::str::contains("bar.txt:1 -> feature/b"))
        .stdout(predicates::str::contains("add bar"));

    // --dry-run rewrites nothing.
    assert_eq!(repo.git(["rev-parse", "HEAD"]), head);
}

#[test]
fn absorb_dry_run_leaves_trunk_owned_and_added_lines_unabsorbed() {
    let repo = stack();

    // README is the trunk's; a fresh line belongs to no commit.
    repo.write("README.md", "# changed\n");
    repo.write("foo.txt", "alpha\nbrand new\n");
    repo.git(["add", "README.md", "foo.txt"]);

    repo.stack()
        .args(["absorb", "--dry-run"])
        .assert()
        .success()
        .stdout(predicates::str::contains("unabsorbed (left in place)"))
        .stdout(predicates::str::contains(
            "README.md:1 owned by a commit outside the stack",
        ))
        .stdout(predicates::str::contains(
            "foo.txt:1 added lines - no commit to attribute",
        ));
}

#[test]
fn absorb_without_staged_changes_reports_nothing() {
    let repo = stack();

    repo.stack()
        .args(["absorb", "--dry-run"])
        .assert()
        .failure()
        .stderr(predicates::str::contains("no staged changes to absorb"));
}

#[test]
fn absorb_include_unstaged_attributes_without_staging() {
    let repo = stack();
    // Edit but do not `git add`.
    repo.write("foo.txt", "alpha fixed\n");

    // Staged-only sees nothing.
    repo.stack()
        .args(["absorb", "--dry-run"])
        .assert()
        .failure()
        .stderr(predicates::str::contains("no staged changes"));

    // --include-unstaged picks the edit up.
    repo.stack()
        .args(["absorb", "--dry-run", "--include-unstaged"])
        .assert()
        .success()
        .stdout(predicates::str::contains("foo.txt:1 -> feature/a"));
}

#[test]
fn absorb_respects_include_unstaged_config() {
    let repo = stack();
    repo.git(["config", "stk.absorbIncludeUnstaged", "true"]);
    repo.write("foo.txt", "alpha fixed\n");

    repo.stack()
        .args(["absorb", "--dry-run"])
        .assert()
        .success()
        .stdout(predicates::str::contains("foo.txt:1 -> feature/a"));
}

#[test]
fn absorb_folds_a_fix_into_its_owning_commit() {
    let repo = stack();
    repo.write("foo.txt", "alpha fixed\n");
    repo.git(["add", "foo.txt"]);

    repo.stack()
        .args(["absorb"])
        .assert()
        .success()
        .stdout(predicates::str::contains("absorbed 1 hunk into 1 commit"));

    // The fix lands in feature/a's existing commit, not a new one.
    assert_eq!(repo.git(["show", "feature/a:foo.txt"]), "alpha fixed");
    assert_eq!(repo.git(["rev-list", "--count", "main..feature/a"]), "1");
    // Nothing left in the worktree.
    assert!(repo.git(["status", "--porcelain"]).is_empty());
}

#[test]
fn absorb_folds_into_multiple_commits_across_branches() {
    let repo = stack();
    repo.write("foo.txt", "alpha fixed\n");
    repo.write("bar.txt", "beta fixed\n");
    repo.git(["add", "foo.txt", "bar.txt"]);

    repo.stack()
        .args(["absorb"])
        .assert()
        .success()
        .stdout(predicates::str::contains("absorbed 2 hunks into 2 commits"));

    assert_eq!(repo.git(["show", "feature/a:foo.txt"]), "alpha fixed");
    assert_eq!(repo.git(["show", "feature/b:bar.txt"]), "beta fixed");
    assert!(repo.git(["status", "--porcelain"]).is_empty());
}

#[test]
fn absorb_leaves_unattributable_changes_in_place() {
    let repo = stack();
    repo.write("foo.txt", "alpha fixed\n"); // -> feature/a
    repo.write("README.md", "# changed\n"); // trunk-owned
    repo.git(["add", "foo.txt", "README.md"]);

    repo.stack()
        .args(["absorb"])
        .assert()
        .success()
        .stdout(predicates::str::contains("absorbed 1 hunk into 1 commit"))
        .stdout(predicates::str::contains(
            "README.md:1 owned by a commit outside the stack",
        ));

    // foo folded; README untouched in history but kept in the worktree.
    assert_eq!(repo.git(["show", "feature/a:foo.txt"]), "alpha fixed");
    assert_eq!(repo.git(["show", "HEAD:README.md"]), "# test repo");
    assert_eq!(
        std::fs::read_to_string(repo.path().join("README.md")).expect("README"),
        "# changed\n"
    );
}

#[test]
fn absorb_requires_running_from_a_leaf() {
    let repo = stack();
    repo.git(["switch", "feature/a"]); // feature/b sits above it

    repo.write("foo.txt", "alpha fixed\n");
    repo.git(["add", "foo.txt"]);

    repo.stack()
        .args(["absorb"])
        .assert()
        .failure()
        .stderr(predicates::str::contains("run `git stk absorb`"))
        .stderr(predicates::str::contains("feature/b"));
}