#![allow(clippy::unwrap_used)]
mod common;
use std::process::Output;
use common::Repo;
fn stdout(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn parent_count(repo: &Repo, rev: &str) -> usize {
let out = repo.ok(&["cat-file", "-p", rev]);
stdout(&out)
.lines()
.filter(|l| l.starts_with("parent "))
.count()
}
fn rev(repo: &Repo, r: &str) -> String {
stdout(&repo.ok(&["rev-parse", r])).trim().to_string()
}
fn clean_diverge(repo: &Repo) {
repo.commit_file("a.txt", b"base\n", "base");
repo.ok(&["branch", "feature"]);
repo.ok(&["checkout", "feature"]);
repo.commit_file("b.txt", b"feat\n", "feat");
repo.ok(&["checkout", "main"]);
repo.commit_file("c.txt", b"main\n", "main2");
}
#[test]
fn config_keys_are_case_insensitive() {
let repo = Repo::new();
repo.ok(&["config", "User.Name", "Ada"]);
assert_eq!(stdout(&repo.ok(&["config", "user.name"])).trim(), "Ada");
repo.ok(&["config", "USER.EMAIL", "ada@example.com"]);
assert_eq!(
stdout(&repo.ok(&["config", "user.email"])).trim(),
"ada@example.com"
);
}
#[test]
fn config_case_variant_cannot_bypass_forbidden_key_guard() {
let repo = Repo::new();
let ident = format!("ed25519:{}", "ab".repeat(32));
repo.ok(&["config", "User.Identity", &ident]);
let cfg = std::fs::read_to_string(repo.mkit_dir().join("config")).unwrap_or_default();
assert!(
!cfg.to_lowercase().contains("identity"),
"case-variant user.identity leaked into the per-repo config: {cfg}"
);
}
#[test]
fn commit_dash_f_reads_message_from_file() {
let repo = Repo::new();
repo.commit_file("a.txt", b"v1\n", "first");
repo.write("a.txt", b"v2\n");
repo.ok(&["add", "a.txt"]);
repo.write("MSG", b"message from -F\n");
repo.ok(&["commit", "-F", "MSG"]);
let log = stdout(&repo.ok(&["log", "--oneline"]));
assert!(
log.lines().next().unwrap_or("").contains("message from -F"),
"log subject did not come from -F file: {log}"
);
}
#[test]
fn merge_no_commit_then_commit_records_two_parents() {
let repo = Repo::new();
clean_diverge(&repo);
let head_before = stdout(&repo.ok(&["rev-parse", "HEAD"]));
let out = repo.ok(&["merge", "--no-commit", "feature"]);
let msg = String::from_utf8_lossy(&out.stderr);
assert!(
msg.contains("stopped before committing"),
"unexpected merge --no-commit message: {msg}"
);
assert_eq!(stdout(&repo.ok(&["rev-parse", "HEAD"])), head_before);
assert!(repo.path().join("b.txt").exists());
repo.ok(&["commit", "-m", "Merge feature"]);
assert_eq!(parent_count(&repo, "HEAD"), 2);
assert_ne!(stdout(&repo.ok(&["rev-parse", "HEAD"])), head_before);
}
#[test]
fn merge_continue_also_finishes_a_no_commit_merge() {
let repo = Repo::new();
clean_diverge(&repo);
repo.ok(&["merge", "--no-commit", "feature"]);
repo.ok(&["merge", "--continue"]);
assert_eq!(parent_count(&repo, "HEAD"), 2);
}
#[test]
fn merge_dash_m_overrides_message() {
let repo = Repo::new();
clean_diverge(&repo);
repo.ok(&["merge", "-m", "custom merge subject", "feature"]);
let log = stdout(&repo.ok(&["log", "--oneline"]));
assert!(
log.contains("custom merge subject"),
"merge -m message not used: {log}"
);
}
#[test]
fn cherry_pick_no_commit_stages_without_committing() {
let repo = Repo::new();
repo.commit_file("a.txt", b"base\n", "base");
repo.ok(&["branch", "feature"]);
repo.ok(&["checkout", "feature"]);
repo.commit_file("d.txt", b"picked\n", "add d");
let pick = stdout(&repo.ok(&["rev-parse", "HEAD"]));
let pick = pick.trim();
repo.ok(&["checkout", "main"]);
let head_before = stdout(&repo.ok(&["rev-parse", "HEAD"]));
repo.ok(&["cherry-pick", "-n", pick]);
assert_eq!(stdout(&repo.ok(&["rev-parse", "HEAD"])), head_before);
assert!(repo.path().join("d.txt").exists());
repo.ok(&["commit", "-m", "took d"]);
assert_eq!(parent_count(&repo, "HEAD"), 1);
}
#[test]
fn cherry_pick_m_is_mainline_selection_not_a_message() {
let repo = Repo::new();
repo.commit_file("a.txt", b"base\n", "c0");
repo.ok(&["branch", "feature"]);
repo.ok(&["checkout", "feature"]);
repo.commit_file("b.txt", b"feat\n", "fb");
repo.ok(&["checkout", "main"]);
repo.commit_file("c.txt", b"main\n", "mc");
let mc = rev(&repo, "HEAD");
repo.ok(&["checkout", "feature"]);
repo.ok(&["merge", "main", "-m", "merge main into feature"]);
assert_eq!(parent_count(&repo, "HEAD"), 2, "M must be a merge");
let m = rev(&repo, "HEAD");
repo.ok(&["checkout", "main"]);
let out = repo.run(&["cherry-pick", "-m", "1", &mc]);
assert!(!out.status.success(), "-m on a non-merge must fail");
assert!(
String::from_utf8_lossy(&out.stderr).contains("not a merge"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = repo.run(&["cherry-pick", &m]);
assert!(!out.status.success(), "merge pick without -m must fail");
assert!(
String::from_utf8_lossy(&out.stderr).contains("mainline"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(!repo.path().join("b.txt").exists());
let out = repo.run(&["cherry-pick", "-m", "2", &m]);
assert!(
out.status.success(),
"cherry-pick -m 2 <merge> should succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
repo.path().join("b.txt").exists(),
"mainline 2 should add b.txt"
);
let out = repo.run(&["cherry-pick", "-m", "notanumber", &m]);
assert!(
!out.status.success(),
"non-numeric -m must be a usage error"
);
}
#[test]
fn branch_contains_and_merged_filter_by_ancestry() {
let repo = Repo::new();
repo.commit_file("a.txt", b"base\n", "base");
repo.ok(&["branch", "feature"]); repo.commit_file("a.txt", b"more\n", "c1"); let c1 = stdout(&repo.ok(&["rev-parse", "HEAD"]));
let c1 = c1.trim();
let contains = stdout(&repo.ok(&["branch", "--contains", c1]));
assert!(contains.contains("main"), "expected main: {contains}");
assert!(
!contains.contains("feature"),
"feature should not contain c1: {contains}"
);
let merged = stdout(&repo.ok(&["branch", "--merged"]));
assert!(merged.contains("main") && merged.contains("feature"));
let no_merged = stdout(&repo.ok(&["branch", "--no-merged"]));
assert!(
!no_merged.contains("feature") && !no_merged.contains("main"),
"nothing should be unmerged: {no_merged}"
);
}
#[test]
fn show_stat_renders_diffstat() {
let repo = Repo::new();
repo.commit_file("a.txt", b"l1\nl2\n", "base");
let out = repo.ok(&["show", "--stat", "HEAD"]);
let s = stdout(&out);
assert!(s.contains("a.txt"), "stat missing file: {s}");
assert!(s.contains("file changed"), "stat missing summary: {s}");
assert!(!s.contains("@@"), "show --stat should omit the patch: {s}");
}
#[test]
fn diff_merge_base_two_revs() {
let repo = Repo::new();
clean_diverge(&repo); let out = repo.ok(&["diff", "--merge-base", "feature", "main", "--name-only"]);
let names = stdout(&out);
assert!(
names.contains("c.txt"),
"merge-base diff should show main's added file: {names}"
);
}
#[test]
fn stash_accepts_stash_at_brace_syntax() {
let repo = Repo::new();
repo.commit_file("a.txt", b"base\n", "base");
repo.write("a.txt", b"dirty1\n");
repo.ok(&["stash"]);
repo.write("a.txt", b"dirty2\n");
repo.ok(&["stash", "save", "-m", "second"]);
let list = stdout(&repo.ok(&["stash", "list"]));
assert!(list.contains("stash@{0}") && list.contains("stash@{1}"));
repo.ok(&["stash", "pop", "stash@{1}"]);
let bad = repo.run(&["stash", "show", "stash@{nope}"]);
assert!(!bad.status.success());
}
#[test]
fn stash_pop_index_restores_staged_state() {
let repo = Repo::new();
repo.commit_file("a.txt", b"v1\n", "c0");
repo.write("a.txt", b"v2\n");
repo.ok(&["add", "a.txt"]);
repo.write("a.txt", b"v3\n");
repo.ok(&["stash"]);
assert_eq!(std::fs::read(repo.path().join("a.txt")).unwrap(), b"v1\n");
repo.ok(&["stash", "pop", "--index"]);
assert_eq!(std::fs::read(repo.path().join("a.txt")).unwrap(), b"v3\n");
let staged = stdout(&repo.ok(&["diff", "--staged"]));
assert!(
staged.contains("+v2") && staged.contains("-v1"),
"--index should restore the staged state: {staged}"
);
}
#[test]
fn stash_pop_index_preserves_staged_deletion() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c0");
repo.write("b.txt", b"b\n");
repo.ok(&["add", "b.txt"]);
repo.ok(&["commit", "-m", "add b"]);
repo.ok(&["rm", "b.txt"]); repo.ok(&["stash"]);
assert!(
repo.path().join("b.txt").exists(),
"save reset the worktree"
);
repo.ok(&["stash", "pop", "--index"]);
assert!(
!repo.path().join("b.txt").exists(),
"worktree deletion restored"
);
let status = stdout(&repo.ok(&["status", "--porcelain"]));
assert!(
status.contains("D b.txt"),
"the staged deletion must survive --index (not become unstaged): {status}"
);
}
#[test]
fn stash_pop_without_index_leaves_index_unstaged() {
let repo = Repo::new();
repo.commit_file("a.txt", b"v1\n", "c0");
repo.write("a.txt", b"v2\n");
repo.ok(&["add", "a.txt"]);
repo.write("a.txt", b"v3\n");
repo.ok(&["stash"]);
repo.ok(&["stash", "pop"]);
assert_eq!(std::fs::read(repo.path().join("a.txt")).unwrap(), b"v3\n");
let staged = stdout(&repo.ok(&["diff", "--staged"]));
assert!(
!staged.contains("+v2"),
"staged state must not be restored without --index: {staged}"
);
}
#[test]
fn branch_list_filters_by_glob_pattern() {
let repo = Repo::new();
repo.commit_file("a.txt", b"base\n", "base");
repo.ok(&["branch", "feature/login"]);
repo.ok(&["branch", "feature/signup"]);
repo.ok(&["branch", "release"]);
let feat = stdout(&repo.ok(&["branch", "--list", "feature/*"]));
assert!(feat.contains("feature/login") && feat.contains("feature/signup"));
assert!(!feat.contains("release"), "release leaked: {feat}");
assert!(!feat.contains("main"), "main leaked: {feat}");
let one = stdout(&repo.ok(&["branch", "--list", "release"]));
assert!(one.contains("release"));
assert!(!one.contains("feature"), "pattern over-matched: {one}");
let all = stdout(&repo.ok(&["branch", "--list"]));
assert!(all.contains("main") && all.contains("release") && all.contains("feature/login"));
let combo = stdout(&repo.ok(&["branch", "--contains", "HEAD", "feature/log*"]));
assert!(combo.contains("feature/login"));
assert!(!combo.contains("feature/signup") && !combo.contains("release"));
}
#[test]
fn config_preserves_remote_subsection_case_across_reload() {
let repo = Repo::new();
repo.commit_file("a.txt", b"x\n", "c0");
repo.ok(&["remote", "add", "Origin", "mkit+file:///tmp/xyz"]);
let listed = stdout(&repo.ok(&["remote"]));
assert!(
listed.contains("Origin"),
"remote subsection name was lowercased on reload: {listed}"
);
}
#[test]
fn config_file_keys_are_case_insensitive() {
let repo = Repo::new();
let cfg_path = repo.mkit_dir().join("config");
let mut body = std::fs::read_to_string(&cfg_path).unwrap_or_default();
body.push_str("\nUser.Name = Zoe\n");
std::fs::write(&cfg_path, body).unwrap();
assert_eq!(stdout(&repo.ok(&["config", "user.name"])).trim(), "Zoe");
}
#[test]
fn cherry_pick_n_on_conflict_is_refused_cleanly() {
let repo = Repo::new();
repo.commit_file("a.txt", b"base\n", "c0");
repo.ok(&["branch", "feature"]);
repo.ok(&["checkout", "feature"]);
repo.commit_file("a.txt", b"theirs\n", "feat");
let pick = rev(&repo, "HEAD");
repo.ok(&["checkout", "main"]);
repo.commit_file("a.txt", b"ours\n", "main2");
let head_before = rev(&repo, "HEAD");
let out = repo.run(&["cherry-pick", "-n", &pick]);
assert!(
!out.status.success(),
"a conflicting -n pick must be refused"
);
assert_eq!(rev(&repo, "HEAD"), head_before, "-n must not move HEAD");
let body = std::fs::read(repo.path().join("a.txt")).unwrap();
assert_eq!(
body, b"ours\n",
"worktree must be untouched on a refused -n pick"
);
assert!(
!String::from_utf8_lossy(&body).contains("<<<<<<<"),
"no conflict markers should be materialized"
);
let cont = repo.run(&["cherry-pick", "--continue"]);
assert!(!cont.status.success());
assert!(
String::from_utf8_lossy(&cont.stderr).contains("no cherry-pick in progress"),
"a refused -n pick must leave no resumable state: {}",
String::from_utf8_lossy(&cont.stderr)
);
}
#[test]
fn merge_no_commit_empty_result_can_still_commit() {
let repo = Repo::new();
repo.commit_file("a.txt", b"base\n", "c0");
repo.ok(&["branch", "feature"]);
repo.ok(&["checkout", "feature"]);
repo.ok(&["rm", "a.txt"]);
repo.ok(&["commit", "-m", "feature deletes a"]);
repo.ok(&["checkout", "main"]);
repo.ok(&["rm", "a.txt"]);
repo.ok(&["commit", "-m", "main deletes a"]);
repo.ok(&["merge", "--no-commit", "feature"]);
let out = repo.run(&["commit", "-m", "empty merge"]);
assert!(
out.status.success(),
"empty-result merge must be committable: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(parent_count(&repo, "HEAD"), 2, "must be a two-parent merge");
}
#[test]
fn merge_no_commit_finish_uses_orig_base_as_first_parent() {
let repo = Repo::new();
clean_diverge(&repo); let main_before = rev(&repo, "HEAD");
let feature = rev(&repo, "feature");
repo.ok(&["merge", "--no-commit", "feature"]);
repo.ok(&["commit", "-m", "merge feature"]);
let body = stdout(&repo.ok(&["cat-file", "-p", "HEAD"]));
let parents: Vec<&str> = body
.lines()
.filter_map(|l| l.strip_prefix("parent "))
.collect();
assert_eq!(
parents.len(),
2,
"merge commit must have two parents: {body}"
);
assert_eq!(parents[0], main_before, "parent[0] must be the merge base");
assert_eq!(
parents[1], feature,
"parent[1] must be MERGE_HEAD (feature)"
);
}
#[test]
fn branch_contains_defaults_to_head() {
let repo = Repo::new();
repo.commit_file("a.txt", b"base\n", "c0");
repo.ok(&["branch", "feature"]); repo.commit_file("a.txt", b"more\n", "c1");
let contains = stdout(&repo.ok(&["branch", "--contains"]));
assert!(
contains.contains("main") && !contains.contains("feature"),
"{contains}"
);
let no_contains = stdout(&repo.ok(&["branch", "--no-contains"]));
assert!(
no_contains.contains("feature") && !no_contains.contains("main"),
"{no_contains}"
);
}
#[test]
fn rebase_replays_a_branch_containing_a_merge_commit() {
let repo = Repo::new();
repo.commit_file("a.txt", b"base\n", "c0");
repo.ok(&["branch", "topic"]);
repo.ok(&["checkout", "topic"]);
repo.commit_file("t.txt", b"t\n", "A");
repo.ok(&["branch", "side"]);
repo.ok(&["checkout", "side"]);
repo.commit_file("s.txt", b"s\n", "S");
repo.ok(&["checkout", "topic"]);
repo.commit_file("t1.txt", b"t1\n", "A2"); repo.ok(&["merge", "side"]); assert_eq!(
parent_count(&repo, "HEAD"),
2,
"topic tip should be a merge"
);
repo.commit_file("t2.txt", b"t2\n", "B");
repo.ok(&["checkout", "main"]);
repo.commit_file("m.txt", b"m\n", "c1");
repo.ok(&["checkout", "topic"]);
let out = repo.run(&["rebase", "main"]);
assert!(
out.status.success(),
"rebase of a branch containing a merge must succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
for f in ["a.txt", "m.txt", "t.txt", "t1.txt", "s.txt", "t2.txt"] {
assert!(repo.path().join(f).exists(), "missing {f} after rebase");
}
}
#[test]
fn diff_merge_base_typo_second_rev_errors() {
let repo = Repo::new();
clean_diverge(&repo); let out = repo.run(&[
"diff",
"--merge-base",
"feature",
"nosuchrev",
"--name-only",
]);
assert!(
!out.status.success(),
"an unresolvable 2nd revision must fail, not exit 0: {}",
String::from_utf8_lossy(&out.stdout)
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("bad revision"),
"expected a bad-revision error: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn merge_abort_after_clean_no_commit_succeeds() {
let repo = Repo::new();
clean_diverge(&repo); let head_before = rev(&repo, "HEAD");
repo.ok(&["merge", "--no-commit", "feature"]);
assert!(repo.path().join("b.txt").exists(), "merge staged b.txt");
repo.ok(&["merge", "--abort"]);
assert!(
!repo.path().join("b.txt").exists(),
"abort discarded the merge"
);
assert_eq!(rev(&repo, "HEAD"), head_before, "HEAD restored");
assert!(
stdout(&repo.ok(&["status", "--porcelain"]))
.trim()
.is_empty()
);
}
#[test]
fn merge_abort_refuses_unstaged_edits_after_no_commit() {
let repo = Repo::new();
clean_diverge(&repo);
repo.ok(&["merge", "--no-commit", "feature"]);
repo.write("b.txt", b"hand-edited after the merge\n"); let out = repo.run(&["merge", "--abort"]);
assert!(
!out.status.success(),
"abort must refuse to discard the edit"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("b.txt"),
"the refusal must name the protected path: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
std::fs::read(repo.path().join("b.txt")).unwrap(),
b"hand-edited after the merge\n"
);
}
#[test]
fn cherry_pick_n_of_a_deletion_stages_the_removal() {
let repo = Repo::new();
repo.commit_file("only.txt", b"x\n", "c0");
repo.ok(&["branch", "del"]);
repo.ok(&["checkout", "del"]);
repo.ok(&["rm", "only.txt"]);
repo.ok(&["commit", "-m", "delete only.txt"]);
let del = rev(&repo, "HEAD");
repo.ok(&["checkout", "main"]);
repo.ok(&["cherry-pick", "-n", &del]);
let status = stdout(&repo.ok(&["status", "--porcelain"]));
assert!(
status.contains("D only.txt"),
"the deletion must be staged: {status}"
);
let out = repo.run(&["commit", "-m", "applied deletion"]);
assert!(
out.status.success(),
"commit of a staged deletion must succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn conflict_plus_clean(repo: &Repo) -> String {
repo.commit_file("a.txt", b"base\n", "c0");
repo.ok(&["branch", "feature"]);
repo.ok(&["checkout", "feature"]);
repo.write("a.txt", b"theirs\n");
repo.write("b.txt", b"clean add from feature\n");
repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "feat"]);
let tip = rev(repo, "HEAD");
repo.ok(&["checkout", "main"]);
repo.write("a.txt", b"ours\n");
repo.ok(&["add", "a.txt"]);
repo.ok(&["commit", "-m", "main2"]);
tip
}
#[test]
fn merge_abort_discards_operation_authored_clean_paths() {
let repo = Repo::new();
conflict_plus_clean(&repo);
let head0 = rev(&repo, "HEAD");
let out = repo.run(&["merge", "feature"]);
assert!(
!out.status.success(),
"merge should pause on the a.txt conflict"
);
assert!(
repo.path().join("b.txt").exists(),
"the clean add was materialized"
);
repo.ok(&["merge", "--abort"]);
assert!(
!repo.path().join("b.txt").exists(),
"abort discarded the clean op-authored file"
);
assert_eq!(rev(&repo, "HEAD"), head0, "HEAD restored");
assert!(
stdout(&repo.ok(&["status", "--porcelain"]))
.trim()
.is_empty()
);
}
#[test]
fn merge_abort_still_protects_unrelated_staged_work() {
let repo = Repo::new();
conflict_plus_clean(&repo);
repo.run(&["merge", "feature"]);
repo.write("unrelated.txt", b"my own work\n");
repo.ok(&["add", "unrelated.txt"]);
let out = repo.run(&["merge", "--abort"]);
assert!(
!out.status.success(),
"abort must refuse to discard unrelated staged work"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("unrelated.txt"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn cherry_pick_continue_preserves_unstaged_clean_path_edits() {
let repo = Repo::new();
let pick = conflict_plus_clean(&repo);
let out = repo.run(&["cherry-pick", &pick]);
assert!(!out.status.success(), "cherry-pick should pause on a.txt");
repo.write("a.txt", b"resolved\n");
repo.ok(&["add", "a.txt"]);
repo.write("b.txt", b"my unstaged edit\n"); repo.ok(&["cherry-pick", "--continue"]);
assert_eq!(
std::fs::read(repo.path().join("b.txt")).unwrap(),
b"my unstaged edit\n",
"the unstaged edit on the clean path must survive --continue"
);
}
#[test]
fn diff_merge_base_accepts_tracked_but_deleted_pathspec() {
let repo = Repo::new();
clean_diverge(&repo); std::fs::remove_file(repo.path().join("a.txt")).unwrap(); let out = repo.run(&["diff", "--merge-base", "feature", "a.txt", "--name-only"]);
assert!(
out.status.success(),
"tracked-but-deleted pathspec must be accepted, not rejected as a bad rev: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn stash_index_round_trip_in_an_unborn_repo() {
let repo = Repo::new();
repo.write("s.txt", b"staged\n");
repo.ok(&["add", "s.txt"]);
repo.ok(&["stash"]);
assert!(
!repo.path().join("s.txt").exists(),
"stash removed the staged file"
);
repo.ok(&["stash", "pop", "--index"]);
let status = stdout(&repo.ok(&["status", "--porcelain"]));
assert!(
status.contains("A s.txt"),
"the file must come back STAGED, not untracked: {status}"
);
}
#[test]
fn merge_abort_preserves_edits_to_operation_authored_clean_paths() {
let repo = Repo::new();
conflict_plus_clean(&repo);
repo.run(&["merge", "feature"]); repo.write("b.txt", b"my edit to the clean file\n");
let out = repo.run(&["merge", "--abort"]);
assert!(
!out.status.success(),
"abort must refuse to discard the edit to b.txt"
);
assert_eq!(
std::fs::read(repo.path().join("b.txt")).unwrap(),
b"my edit to the clean file\n",
"the edit to the cleanly-merged file must survive"
);
}
#[test]
fn merge_abort_after_no_commit_protects_staged_unrelated_work() {
let repo = Repo::new();
clean_diverge(&repo);
repo.ok(&["merge", "--no-commit", "feature"]);
repo.write("unrelated.txt", b"my own staged work\n");
repo.ok(&["add", "unrelated.txt"]);
let out = repo.run(&["merge", "--abort"]);
assert!(
!out.status.success(),
"abort must protect staged unrelated work"
);
assert!(
repo.path().join("unrelated.txt").exists(),
"the staged file must survive"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("unrelated.txt"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn stash_index_round_trip_in_unborn_repo_with_nested_path() {
let repo = Repo::new();
repo.write("dir/s.txt", b"staged\n");
repo.ok(&["add", "."]);
repo.ok(&["stash"]);
assert!(
!repo.path().join("dir").exists(),
"stash should remove the now-empty parent directory"
);
repo.ok(&["stash", "pop", "--index"]);
let status = stdout(&repo.ok(&["status", "--porcelain"]));
assert!(
status.contains("A dir/s.txt"),
"the nested file must come back STAGED: {status}"
);
}
#[test]
fn diff_merge_base_typo_branch_with_slash_errors() {
let repo = Repo::new();
clean_diverge(&repo);
let out = repo.run(&[
"diff",
"--merge-base",
"main",
"feature/typo",
"--name-only",
]);
assert!(
!out.status.success(),
"a /-containing typo'd branch must be a bad revision: stdout={:?}",
stdout(&out)
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("bad revision"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn rebase_skip_preserves_edits_to_operation_authored_clean_paths() {
let repo = Repo::new();
repo.commit_file("a.txt", b"base\n", "c0");
repo.ok(&["branch", "topic"]);
repo.ok(&["checkout", "topic"]);
repo.write("a.txt", b"topic\n");
repo.write("b.txt", b"clean\n");
repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "topic work"]);
repo.ok(&["checkout", "main"]);
repo.commit_file("a.txt", b"main\n", "main work");
repo.ok(&["checkout", "topic"]);
let out = repo.run(&["rebase", "main"]);
assert!(
!out.status.success(),
"rebase should pause on the a.txt conflict"
);
repo.write("b.txt", b"my edit during rebase\n");
let skip = repo.run(&["rebase", "--skip"]);
assert!(
!skip.status.success(),
"skip must refuse to discard the edit to b.txt"
);
assert_eq!(
std::fs::read(repo.path().join("b.txt")).unwrap(),
b"my edit during rebase\n"
);
repo.run(&["rebase", "--abort"]);
}
#[test]
fn merge_abort_refuses_restoring_a_deleted_file_over_a_user_directory() {
let repo = Repo::new();
repo.write("a.txt", b"base\n");
repo.write("d", b"file d\n");
repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "c0"]);
repo.ok(&["branch", "feature"]);
repo.ok(&["checkout", "feature"]);
repo.write("a.txt", b"theirs\n");
repo.ok(&["rm", "d"]);
repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "feat"]);
repo.ok(&["checkout", "main"]);
repo.write("a.txt", b"ours\n");
repo.ok(&["add", "a.txt"]);
repo.ok(&["commit", "-m", "m2"]);
repo.run(&["merge", "feature"]); std::fs::create_dir(repo.path().join("d")).unwrap();
repo.write("d/keep", b"my untracked work\n");
let out = repo.run(&["merge", "--abort"]);
assert!(
!out.status.success(),
"abort must refuse the file-over-directory restore"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("replace directory"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
repo.path().join("d/keep").exists(),
"untracked content must survive"
);
}
#[test]
fn stash_in_unborn_repo_clears_and_restores_untracked_too() {
let repo = Repo::new();
repo.write("s.txt", b"staged\n");
repo.write("u.txt", b"untracked\n");
repo.ok(&["add", "s.txt"]);
repo.ok(&["stash"]);
assert!(
!repo.path().join("s.txt").exists() && !repo.path().join("u.txt").exists(),
"stash should leave a clean slate"
);
repo.ok(&["stash", "pop", "--index"]);
let status = stdout(&repo.ok(&["status", "--porcelain"]));
assert!(
status.contains("A s.txt"),
"staged file restored staged: {status}"
);
assert!(
status.contains("?? u.txt"),
"untracked file restored untracked: {status}"
);
}
#[test]
fn diff_merge_base_accepts_dot_slash_tracked_deleted_pathspec() {
let repo = Repo::new();
clean_diverge(&repo);
std::fs::remove_file(repo.path().join("a.txt")).unwrap();
let out = repo.run(&["diff", "--merge-base", "feature", "./a.txt", "--name-only"]);
assert!(
out.status.success(),
"./a.txt (tracked, deleted) must normalize to a pathspec: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn merge_file_vs_directory_conflict_is_atomic_and_abortable() {
let repo = Repo::new();
repo.commit_file("seed.txt", b"base\n", "c0");
repo.ok(&["branch", "feature"]);
repo.write("p/inner.txt", b"inner\n"); repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "ours dir p/"]);
let head0 = rev(&repo, "HEAD");
repo.ok(&["checkout", "feature"]);
repo.write("p", b"file p\n"); repo.ok(&["add", "p"]);
repo.ok(&["commit", "-m", "theirs file p"]);
repo.ok(&["checkout", "main"]);
let out = repo.run(&["merge", "feature"]);
assert!(!out.status.success(), "the D/F conflict should pause");
assert!(
repo.mkit_dir().join("MERGE_HEAD").exists(),
"MERGE_HEAD must be written — the pause must be atomic with an --abort path"
);
repo.ok(&["merge", "--abort"]);
assert_eq!(rev(&repo, "HEAD"), head0, "HEAD restored");
assert_eq!(
std::fs::read(repo.path().join("p/inner.txt")).unwrap(),
b"inner\n",
"ours directory restored"
);
assert!(
stdout(&repo.ok(&["status", "--porcelain"]))
.trim()
.is_empty()
);
}
fn complementary_deletes(repo: &Repo) {
repo.write("x.txt", b"x\n");
repo.write("y.txt", b"y\n");
repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "c0"]);
repo.ok(&["branch", "feature"]);
repo.ok(&["checkout", "feature"]);
repo.ok(&["rm", "x.txt"]);
repo.ok(&["commit", "-m", "rm x"]);
repo.ok(&["checkout", "main"]);
repo.ok(&["rm", "y.txt"]);
repo.ok(&["commit", "-m", "rm y"]);
}
#[test]
fn merge_continue_after_empty_no_commit_records_deletions() {
let repo = Repo::new();
complementary_deletes(&repo);
repo.ok(&["merge", "--no-commit", "feature"]);
repo.ok(&["merge", "--continue"]);
assert!(
stdout(&repo.ok(&["status", "--porcelain"]))
.trim()
.is_empty(),
"tree must be clean after --continue"
);
assert!(!repo.path().join("x.txt").exists() && !repo.path().join("y.txt").exists());
assert_eq!(parent_count(&repo, "HEAD"), 2, "two-parent merge commit");
}
#[test]
fn merge_abort_after_empty_no_commit_restores_head() {
let repo = Repo::new();
complementary_deletes(&repo);
let head0 = rev(&repo, "HEAD");
repo.ok(&["merge", "--no-commit", "feature"]);
repo.ok(&["merge", "--abort"]);
assert_eq!(rev(&repo, "HEAD"), head0, "HEAD restored to pre-merge");
assert!(repo.path().join("x.txt").exists(), "main's x.txt restored");
assert!(
stdout(&repo.ok(&["status", "--porcelain"]))
.trim()
.is_empty()
);
}
#[test]
fn merge_abort_fails_closed_on_untracked_dir_at_conflict_path() {
let repo = Repo::new();
repo.write("a.txt", b"base\n");
repo.write("p", b"file p\n");
repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "c0"]);
repo.ok(&["branch", "feature"]);
repo.ok(&["rm", "p"]);
repo.write("a.txt", b"ours\n");
repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "ours"]);
repo.ok(&["checkout", "feature"]);
repo.write("p", b"theirs\n");
repo.write("a.txt", b"theirs\n");
repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "theirs"]);
repo.ok(&["checkout", "main"]);
repo.run(&["merge", "feature"]); std::fs::remove_file(repo.path().join("p")).ok();
std::fs::create_dir(repo.path().join("p")).unwrap();
repo.write("p/keep", b"my untracked work\n");
let out = repo.run(&["merge", "--abort"]);
assert!(
!out.status.success(),
"abort must fail closed, not destroy the untracked directory"
);
assert!(
repo.path().join("p/keep").exists(),
"untracked content must be preserved: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn merge_continue_after_file_vs_directory_conflict() {
let repo = Repo::new();
repo.commit_file("seed.txt", b"base\n", "c0");
repo.ok(&["branch", "feature"]);
repo.write("p/inner.txt", b"inner\n");
repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "ours dir p/"]);
repo.ok(&["checkout", "feature"]);
repo.write("p", b"file p\n");
repo.ok(&["add", "p"]);
repo.ok(&["commit", "-m", "theirs file p"]);
repo.ok(&["checkout", "main"]);
repo.run(&["merge", "feature"]); repo.ok(&["merge", "--continue"]);
assert_eq!(parent_count(&repo, "HEAD"), 2, "two-parent merge commit");
assert!(
repo.path().join("p/inner.txt").exists(),
"ours directory kept"
);
}
#[test]
fn merge_abort_fails_closed_when_restore_ancestor_is_now_a_file() {
let repo = Repo::new();
repo.write("a.txt", b"base\n");
repo.write("p/file", b"pf\n");
repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "c0"]);
repo.ok(&["branch", "feature"]);
repo.write("a.txt", b"ours\n");
repo.ok(&["add", "a.txt"]);
repo.ok(&["commit", "-m", "ours"]);
repo.ok(&["checkout", "feature"]);
repo.write("a.txt", b"theirs\n");
repo.ok(&["rm", "p/file"]);
repo.ok(&["add", "a.txt"]);
repo.ok(&["commit", "-m", "theirs"]);
repo.ok(&["checkout", "main"]);
repo.run(&["merge", "feature"]); std::fs::remove_dir_all(repo.path().join("p")).ok();
repo.write("p", b"my work\n");
let out = repo.run(&["merge", "--abort"]);
assert!(!out.status.success(), "abort must fail closed");
assert_eq!(
std::fs::read(repo.path().join("p")).unwrap(),
b"my work\n",
"the user's file must be preserved"
);
}
#[test]
fn branch_ancestry_filter_rejects_non_commit() {
let repo = Repo::new();
repo.commit_file("a.txt", b"x\n", "c0");
let tree = stdout(&repo.ok(&["cat-file", "-p", "HEAD"]))
.lines()
.find_map(|l| l.strip_prefix("tree ").map(str::to_string))
.expect("tree line");
let out = repo.run(&["branch", "--no-contains", &tree]);
assert!(
!out.status.success(),
"a tree id must be rejected, not silently accepted"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("not a commit"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn diff_merge_base_root_pathspec_matches_everything() {
let repo = Repo::new();
repo.commit_file("a.txt", b"base\n", "c0");
repo.ok(&["branch", "feature"]);
repo.commit_file("a.txt", b"changed\n", "m2");
for spec in [".", "./"] {
let out = stdout(&repo.ok(&["diff", "--merge-base", "feature", spec, "--name-only"]));
assert!(
out.contains("a.txt"),
"root pathspec {spec:?} must match the whole tree, got: {out:?}"
);
}
}
#[test]
fn merge_abort_atomic_when_conflict_path_replaced_by_nonempty_dir() {
let repo = Repo::new();
repo.write("a.txt", b"base\n");
repo.write("p", b"pcontent\n");
repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "c0"]);
repo.ok(&["branch", "feature"]);
repo.write("a.txt", b"ours\n");
repo.ok(&["rm", "p"]);
repo.ok(&["add", "a.txt"]);
repo.ok(&["commit", "-m", "ours"]);
repo.ok(&["checkout", "feature"]);
repo.write("a.txt", b"theirs\n");
repo.write("p", b"pmod\n");
repo.ok(&["add", "."]);
repo.ok(&["commit", "-m", "theirs"]);
repo.ok(&["checkout", "main"]);
let head0 = rev(&repo, "HEAD");
repo.run(&["merge", "feature"]); std::fs::remove_file(repo.path().join("p")).ok();
std::fs::create_dir(repo.path().join("p")).unwrap();
repo.write("p/keep", b"my work\n");
let out = repo.run(&["merge", "--abort"]);
assert!(
!out.status.success(),
"abort must fail closed before mutating"
);
assert_eq!(
std::fs::read(repo.path().join("p/keep")).unwrap(),
b"my work\n",
"the untracked directory content must be preserved"
);
assert_eq!(
rev(&repo, "HEAD"),
head0,
"HEAD untouched — merge still in progress"
);
assert!(
repo.mkit_dir().join("MERGE_HEAD").exists(),
"merge state preserved (retryable)"
);
}
#[test]
fn merge_commit_refused_when_staged_merge_was_reset_away() {
let repo = Repo::new();
repo.commit_file("a.txt", b"base\n", "c0");
repo.ok(&["branch", "feature"]);
repo.ok(&["checkout", "feature"]);
repo.commit_file("b.txt", b"feat\n", "feat");
repo.ok(&["checkout", "main"]);
repo.commit_file("c.txt", b"main\n", "m2");
repo.ok(&["merge", "--no-commit", "feature"]);
repo.ok(&["reset"]); let out = repo.run(&["commit", "-m", "noop merge"]);
assert!(!out.status.success(), "a reset-away merge must not commit");
assert!(
String::from_utf8_lossy(&out.stderr).contains("nothing to commit"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
repo.mkit_dir().join("MERGE_HEAD").exists(),
"the merge must remain in progress (abortable)"
);
}
#[test]
fn global_dash_c_changes_directory() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "first");
let parent = repo.path().parent().expect("tempdir has a parent");
let out = common::mkit(
parent,
repo.xdg(),
&[
"-C",
repo.path().to_str().unwrap(),
"rev-parse",
"--abbrev-ref",
"HEAD",
],
);
assert!(out.status.success(), "-C rev-parse failed: {out:?}");
assert_eq!(stdout(&out).trim(), "main");
}
#[test]
fn global_dash_c_override_applies_inert_key() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "first");
let out = repo.ok(&["-c", "user.email=ci@example.com", "config", "user.email"]);
assert_eq!(stdout(&out).trim(), "ci@example.com");
}
#[test]
fn global_dash_c_refuses_forbidden_key() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "first");
let out = repo.run(&["-c", "user.identity=mid:999999", "config", "user.identity"]);
let printed = stdout(&out);
assert!(
!printed.contains("999999"),
"-c must not override the signed identity: {printed}"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("ignoring `-c user.identity"),
"expected a refusal warning, got: {stderr}"
);
}
#[test]
fn global_dash_c_drops_denied_core_key() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "first");
let out = repo.run(&["-c", "core.pager=less", "config", "core.pager"]);
assert!(
!out.status.success(),
"denied core.* via -c must not be readable back: {out:?}"
);
}
#[test]
fn switch_creates_and_switches() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
let out = repo.ok(&["switch", "-c", "feature"]);
assert!(
String::from_utf8_lossy(&out.stderr).contains("Switched to a new branch 'feature'"),
"switch -c output: {out:?}"
);
assert_eq!(
stdout(&repo.ok(&["branch", "--show-current"])).trim(),
"feature"
);
let back = repo.ok(&["switch", "main"]);
assert!(String::from_utf8_lossy(&back.stderr).contains("Switched to branch 'main'"));
}
#[test]
fn checkout_dash_b_creates_new_branch() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
let head_before = rev(&repo, "HEAD");
let out = repo.ok(&["checkout", "-b", "topic"]);
assert!(String::from_utf8_lossy(&out.stderr).contains("Switched to a new branch 'topic'"));
assert_eq!(
stdout(&repo.ok(&["symbolic-ref", "--short", "HEAD"])).trim(),
"topic",
"HEAD did not move to the new branch 'topic'"
);
assert_eq!(
rev(&repo, "refs/heads/topic"),
head_before,
"new branch 'topic' does not point at the pre-checkout commit"
);
}
#[test]
fn rev_list_count_and_listing() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
repo.commit_file("b.txt", b"b\n", "c2");
assert_eq!(
stdout(&repo.ok(&["rev-list", "--count", "HEAD"])).trim(),
"2"
);
let listing = stdout(&repo.ok(&["rev-list", "HEAD"]));
assert_eq!(listing.lines().count(), 2);
}
#[test]
fn merge_base_and_is_ancestor() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "base");
let base = rev(&repo, "HEAD");
assert!(repo.ok(&["branch", "feature"]).status.success());
repo.commit_file("b.txt", b"b\n", "main2");
let mb = stdout(&repo.ok(&["merge-base", "main", "feature"]));
assert_eq!(mb.trim(), base);
assert!(
repo.run(&["merge-base", "--is-ancestor", "feature", "main"])
.status
.success()
);
assert!(
!repo
.run(&["merge-base", "--is-ancestor", "main", "feature"])
.status
.success()
);
}
#[test]
fn tag_list_glob_filters() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
repo.ok(&["tag", "v1.0"]);
repo.ok(&["tag", "v2.0"]);
repo.ok(&["tag", "nightly"]);
let out = stdout(&repo.ok(&["tag", "-l", "v*"]));
assert!(out.contains("v1.0") && out.contains("v2.0"));
assert!(
!out.contains("nightly"),
"glob must exclude non-matches: {out}"
);
}
#[test]
fn diff_exit_code_reports_difference() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
assert!(repo.run(&["diff", "--exit-code"]).status.success());
repo.write("a.txt", b"changed\n");
let out = repo.run(&["diff", "--quiet"]);
assert!(!out.status.success(), "dirty --quiet must exit non-zero");
assert!(out.stdout.is_empty(), "--quiet must print nothing");
}
#[test]
fn diff_color_always_and_never() {
let repo = Repo::new();
repo.commit_file("f.txt", b"a\nb\nc\n", "c1");
repo.write("f.txt", b"a\nB\nc\n");
let colored = stdout(&repo.ok(&["diff", "--color=always"]));
assert!(
colored.contains('\u{1b}'),
"expected ANSI codes: {colored:?}"
);
assert!(colored.contains("\u{1b}[32m"), "expected green additions");
assert!(
!stdout(&repo.ok(&["diff"])).contains('\u{1b}'),
"piped diff must be plain"
);
assert!(
!stdout(&repo.ok(&["diff", "--no-color"])).contains('\u{1b}'),
"--no-color must be plain"
);
}
#[test]
fn reset_quiet_suppresses_summary() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
repo.commit_file("b.txt", b"b\n", "c2");
let out = repo.ok(&["reset", "-q", "--hard", "HEAD~1"]);
assert!(
String::from_utf8_lossy(&out.stderr).trim().is_empty(),
"reset -q must be silent: {:?}",
String::from_utf8_lossy(&out.stderr)
);
repo.commit_file("c.txt", b"c\n", "c3");
let loud = repo.ok(&["reset", "--hard", "HEAD~1"]);
assert!(String::from_utf8_lossy(&loud.stderr).contains("HEAD is now at"));
}
#[test]
fn stash_pop_on_empty_reports_and_exits_nonzero() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
let out = repo.run(&["stash", "pop"]);
assert!(!out.status.success(), "empty stash pop must exit nonzero");
assert!(
String::from_utf8_lossy(&out.stderr).contains("No stash entries found."),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn rebase_noninteractive_up_to_date_message() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
assert!(repo.ok(&["branch", "feature"]).status.success());
let out = repo.ok(&["rebase", "feature"]);
assert!(
String::from_utf8_lossy(&out.stderr).contains("is up to date"),
"rebase up-to-date: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn revert_accepts_no_edit() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
let out = repo.ok(&["revert", "--no-edit", "HEAD"]);
assert!(out.status.success(), "revert --no-edit failed: {out:?}");
assert!(String::from_utf8_lossy(&out.stderr).contains("Revert \""));
}
#[test]
fn merge_base_and_rev_list_peel_annotated_tags() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
repo.ok(&["tag", "-a", "v1", "-m", "annotated"]);
repo.commit_file("b.txt", b"b\n", "c2");
assert!(
repo.run(&["merge-base", "--is-ancestor", "v1", "HEAD"])
.status
.success(),
"merge-base --is-ancestor must peel the annotated tag (expected exit 0)"
);
assert_eq!(stdout(&repo.ok(&["rev-list", "--count", "v1"])).trim(), "1");
assert!(
!stdout(&repo.ok(&["merge-base", "v1", "HEAD"]))
.trim()
.is_empty()
);
}
#[test]
fn diff_color_with_revision_arg_is_not_greedy() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
repo.write("a.txt", b"changed\n");
let out = repo.run(&["diff", "--color", "HEAD"]);
assert!(
out.status.success(),
"diff --color <rev> must not bind the rev as the color value: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn dash_c_enforced_on_read_or_default_path() {
let repo = Repo::new();
repo.write("a.txt", b"a\n");
repo.ok(&["add", "a.txt"]);
let out = repo.run(&[
"-c",
"user.email=ci@example.com",
"-c",
"user.identity=mid:99999",
"commit",
"-m",
"c1",
]);
assert!(out.status.success(), "commit failed: {out:?}");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("ignoring `-c user.identity"),
"forbidden -c must be refused on the commit (read_or_default) path: {stderr}"
);
assert!(
!stderr.contains("ignoring `-c user.email"),
"inert -c must be accepted, not warned: {stderr}"
);
}
#[test]
fn checkout_dash_b_is_atomic_on_refused_switch() {
let repo = Repo::new();
repo.commit_file("a.txt", b"1\n", "c1");
let c1 = rev(&repo, "HEAD");
repo.commit_file("a.txt", b"2\n", "c2");
repo.write("a.txt", b"local-dirty\n"); let out = repo.run(&["checkout", "-b", "newbranch", &c1]);
assert!(!out.status.success(), "dirty -b switch must be refused");
let listed = stdout(&repo.ok(&["branch", "--list", "newbranch"]));
assert!(
!listed.contains("newbranch"),
"refused checkout -b must not create the branch: {listed:?}"
);
}
#[test]
fn checkout_dash_big_b_reset_existing_branch_message() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
assert!(repo.ok(&["branch", "feature"]).status.success());
let out = repo.run(&["checkout", "-B", "feature"]);
assert!(out.status.success(), "checkout -B failed: {out:?}");
assert!(
String::from_utf8_lossy(&out.stderr).contains("Reset branch 'feature'"),
"git-style reset message: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn tag_list_and_delete_are_mutually_exclusive() {
let repo = Repo::new();
repo.commit_file("a.txt", b"a\n", "c1");
repo.ok(&["tag", "v1"]);
let out = repo.run(&["tag", "-l", "-d", "v1"]);
assert!(!out.status.success(), "tag -l -d must error");
assert!(stdout(&repo.ok(&["tag", "-l"])).contains("v1"));
}
#[test]
fn diff_color_preserves_non_utf8_bytes() {
let repo = Repo::new();
repo.write("f.txt", b"line one \xff end\n");
repo.ok(&["add", "f.txt"]);
repo.ok(&["commit", "-m", "c1"]);
repo.write("f.txt", b"line two \xff end\n");
let out = repo.run(&["diff", "--color=always"]);
assert!(
out.stdout.windows(3).all(|w| w != [0xEF, 0xBF, 0xBD]),
"non-UTF-8 patch bytes must round-trip under --color"
);
assert!(
out.stdout.contains(&0xFF),
"the raw non-UTF-8 byte must be present"
);
}