536 lines
22 KiB
Rust
536 lines
22 KiB
Rust
//! End-to-end tests for the `levcs merge` command.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
fn levcs_bin() -> String {
|
|
env!("CARGO_BIN_EXE_levcs").to_string()
|
|
}
|
|
|
|
fn run(args: &[&str], cwd: &Path, xdg: &Path) -> (i32, String, String) {
|
|
let out = Command::new(levcs_bin())
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.env("XDG_CONFIG_HOME", xdg)
|
|
.output()
|
|
.expect("run levcs");
|
|
(
|
|
out.status.code().unwrap_or(-1),
|
|
String::from_utf8_lossy(&out.stdout).to_string(),
|
|
String::from_utf8_lossy(&out.stderr).to_string(),
|
|
)
|
|
}
|
|
|
|
fn tempdir(prefix: &str) -> PathBuf {
|
|
let mut p = std::env::temp_dir();
|
|
let n = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
p.push(format!("{prefix}-{n}-{}", std::process::id()));
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
p
|
|
}
|
|
|
|
fn init_repo() -> (PathBuf, PathBuf) {
|
|
let work = tempdir("levcs-merge");
|
|
let xdg = work.join("cfg");
|
|
std::fs::create_dir_all(&xdg).unwrap();
|
|
let (code, _, e) = run(&["init", "--key", "alice"], &work, &xdg);
|
|
assert_eq!(code, 0, "init: {e}");
|
|
(work, xdg)
|
|
}
|
|
|
|
#[test]
|
|
fn fast_forward_merge_advances_branch() {
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("a.txt"), b"one\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "first"], &work, &xdg).0, 0);
|
|
|
|
// Create a feature branch off main, switch to it, add a commit.
|
|
assert_eq!(run(&["branch", "--create", "feature"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--switch", "feature"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("b.txt"), b"two\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "add b"], &work, &xdg).0, 0);
|
|
|
|
// Switch back to main and merge feature: should fast-forward.
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
let (code, _, e) = run(&["merge", "feature"], &work, &xdg);
|
|
assert_eq!(code, 0, "fast-forward merge: {e}");
|
|
assert!(
|
|
e.contains("fast-forward"),
|
|
"expected fast-forward message; got {e}"
|
|
);
|
|
assert!(
|
|
work.join("b.txt").is_file(),
|
|
"feature file should be present"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clean_three_way_merge_then_commit_produces_two_parents() {
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("a.txt"), b"hello\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
|
|
|
|
// Branch.
|
|
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
|
|
|
|
// On main: add c.txt.
|
|
std::fs::write(work.join("c.txt"), b"main side\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "main change"], &work, &xdg).0, 0);
|
|
|
|
// Switch to feat and add b.txt.
|
|
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("b.txt"), b"feat side\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "feat change"], &work, &xdg).0, 0);
|
|
|
|
// Back to main, merge feat: clean (disjoint changes).
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
let (code, o, e) = run(&["merge", "feat"], &work, &xdg);
|
|
assert_eq!(code, 0, "merge: {e}");
|
|
assert!(
|
|
o.contains("auto-resolved: 1") || o.contains("auto-resolved: 2"),
|
|
"summary missing: {o}"
|
|
);
|
|
|
|
// Both files should be in the working tree now.
|
|
assert!(work.join("b.txt").is_file());
|
|
assert!(work.join("c.txt").is_file());
|
|
|
|
// MERGE_HEAD should exist before commit, then disappear after.
|
|
let merge_head = work.join(".levcs/MERGE_HEAD");
|
|
assert!(
|
|
merge_head.exists(),
|
|
"MERGE_HEAD should be set before commit"
|
|
);
|
|
|
|
// Finalize.
|
|
let (code, _, e) = run(&["commit", "-m", "merge feat"], &work, &xdg);
|
|
assert_eq!(code, 0, "commit (merge): {e}");
|
|
assert!(
|
|
!merge_head.exists(),
|
|
"MERGE_HEAD should be cleared after commit"
|
|
);
|
|
|
|
// Log should show the merge as the most recent commit.
|
|
let (code, log, _) = run(&["log"], &work, &xdg);
|
|
assert_eq!(code, 0);
|
|
assert!(log.contains("merge feat"));
|
|
}
|
|
|
|
#[test]
|
|
fn conflicting_merge_writes_state_and_blocks_commit_until_resolved() {
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("a.txt"), b"original\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
|
|
|
|
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
|
|
|
|
// Modify a.txt on main.
|
|
std::fs::write(work.join("a.txt"), b"main version\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "main edit"], &work, &xdg).0, 0);
|
|
|
|
// Different modification on feat.
|
|
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("a.txt"), b"feat version\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "feat edit"], &work, &xdg).0, 0);
|
|
|
|
// Merge feat into main: conflict.
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
let (code, _o, e) = run(&["merge", "feat"], &work, &xdg);
|
|
assert_ne!(code, 0, "conflict should produce non-zero exit");
|
|
assert!(
|
|
e.contains("CONFLICT") || e.contains("conflict"),
|
|
"conflict report missing: {e}"
|
|
);
|
|
|
|
// Merge state files exist.
|
|
assert!(work.join(".levcs/MERGE_HEAD").exists());
|
|
assert!(work.join(".levcs/MERGE_BASE").exists());
|
|
assert!(work.join(".levcs/merge-record").exists());
|
|
|
|
// The working file has conflict markers.
|
|
let bytes = std::fs::read(work.join("a.txt")).unwrap();
|
|
let s = String::from_utf8_lossy(&bytes);
|
|
assert!(s.contains("<<<<<<<"));
|
|
assert!(s.contains(">>>>>>>"));
|
|
|
|
// commit must refuse while conflict markers remain.
|
|
let (code, _, e) = run(&["commit", "-m", "premature"], &work, &xdg);
|
|
assert_ne!(code, 0, "commit should refuse: {e}");
|
|
assert!(
|
|
e.contains("conflict markers"),
|
|
"marker check should mention markers: {e}"
|
|
);
|
|
|
|
// Resolve manually and commit.
|
|
std::fs::write(work.join("a.txt"), b"resolved\n").unwrap();
|
|
let (code, _, e) = run(&["commit", "-m", "merge feat"], &work, &xdg);
|
|
assert_eq!(code, 0, "commit after resolution: {e}");
|
|
assert!(!work.join(".levcs/MERGE_HEAD").exists());
|
|
assert!(!work.join(".levcs/merge-record").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn merge_abort_restores_head_and_clears_state() {
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("a.txt"), b"original\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
|
|
|
|
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("a.txt"), b"main version\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "main edit"], &work, &xdg).0, 0);
|
|
|
|
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("a.txt"), b"feat version\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "feat edit"], &work, &xdg).0, 0);
|
|
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
let _ = run(&["merge", "feat"], &work, &xdg);
|
|
assert!(work.join(".levcs/MERGE_HEAD").exists());
|
|
|
|
// Abort.
|
|
let (code, _, e) = run(&["merge", "--abort"], &work, &xdg);
|
|
assert_eq!(code, 0, "abort: {e}");
|
|
assert!(!work.join(".levcs/MERGE_HEAD").exists());
|
|
assert!(!work.join(".levcs/merge-record").exists());
|
|
|
|
// Working tree restored to main's content.
|
|
let s = std::fs::read_to_string(work.join("a.txt")).unwrap();
|
|
assert_eq!(s, "main version\n");
|
|
}
|
|
|
|
#[test]
|
|
fn commit_refuses_merge_record_with_handler_outside_repo_policy() {
|
|
// Set up a merge whose merge-record we'll then hand-edit to reference a
|
|
// disallowed plugin handler. The repo's `.levcs/merge.toml` enforces
|
|
// builtin-only.
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("a.txt"), b"hello\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
|
|
|
|
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("a.txt"), b"main side\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "main"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("b.txt"), b"feat side\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "feat"], &work, &xdg).0, 0);
|
|
|
|
// Switch to main, then write a builtin-only policy and merge.
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
std::fs::write(
|
|
work.join(".levcs/merge.toml"),
|
|
b"[policy]\nallowed_handlers = [\"builtin\"]\n",
|
|
)
|
|
.unwrap();
|
|
let (code, _, _) = run(&["merge", "feat"], &work, &xdg);
|
|
assert_eq!(code, 0, "clean merge against built-ins should pass policy");
|
|
|
|
// Hand-edit the merge-record to reference a forbidden plugin.
|
|
let record_path = work.join(".levcs/merge-record");
|
|
let mut record = std::fs::read_to_string(&record_path).unwrap();
|
|
record.push_str(
|
|
"\n[[file]]\npath = \"x.proto\"\nhandler = \"tree-sitter:protobuf\"\nhandler_hash = \"blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"\nstatus = \"auto\"\n",
|
|
);
|
|
std::fs::write(&record_path, &record).unwrap();
|
|
|
|
let (code, _, e) = run(&["commit", "-m", "merge feat"], &work, &xdg);
|
|
assert_ne!(code, 0, "commit must refuse a record outside policy");
|
|
assert!(
|
|
e.contains("tree-sitter:protobuf"),
|
|
"error must name the bad handler: {e}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_format_json_emits_structured_report_on_clean_merge() {
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("a.txt"), b"hello\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
|
|
|
|
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("c.txt"), b"main side\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "main change"], &work, &xdg).0, 0);
|
|
|
|
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("b.txt"), b"feat side\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "feat change"], &work, &xdg).0, 0);
|
|
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
let (code, stdout, _) = run(&["merge", "--format=json", "feat"], &work, &xdg);
|
|
assert_eq!(code, 0, "clean merge with --format=json must exit 0");
|
|
// stdout must be a single line of valid JSON, parseable.
|
|
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("parse JSON report");
|
|
assert_eq!(v["schema_version"], 1);
|
|
assert_eq!(v["conflicts"], 0);
|
|
assert!(v["auto_resolved"].as_u64().unwrap() >= 1);
|
|
assert!(v["base"].as_str().unwrap().starts_with("blake3:"));
|
|
let files = v["files"].as_array().expect("files array");
|
|
// The human-readable summary must NOT appear on stdout.
|
|
assert!(
|
|
!stdout.contains("merge summary:"),
|
|
"JSON mode must suppress prose summary; got: {stdout}"
|
|
);
|
|
// Each file record carries path + handler + status.
|
|
for f in files {
|
|
assert!(f["path"].is_string());
|
|
assert!(f["handler"].is_string());
|
|
let status = f["status"].as_str().unwrap();
|
|
assert!(
|
|
matches!(status, "merged" | "conflict" | "not_applicable"),
|
|
"unexpected status {status}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn merge_format_json_reports_conflicts_and_exits_nonzero() {
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("a.txt"), b"original\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
|
|
|
|
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("a.txt"), b"main version\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "main edit"], &work, &xdg).0, 0);
|
|
|
|
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("a.txt"), b"feat version\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "feat edit"], &work, &xdg).0, 0);
|
|
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
let (code, stdout, _) = run(&["merge", "--format=json", "feat"], &work, &xdg);
|
|
assert_ne!(code, 0, "conflicting merge must exit non-zero");
|
|
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("parse JSON");
|
|
assert!(v["conflicts"].as_u64().unwrap() >= 1);
|
|
let files = v["files"].as_array().unwrap();
|
|
let conflicted: Vec<_> = files.iter().filter(|f| f["status"] == "conflict").collect();
|
|
assert!(!conflicted.is_empty(), "must report at least one conflict");
|
|
// Conflict regions array is present for conflicted files.
|
|
let f = conflicted[0];
|
|
assert!(f["conflict_regions"].as_u64().unwrap() >= 1);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_format_json_rejects_unknown_value() {
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("a.txt"), b"x\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "x"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--create", "f"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("a.txt"), b"y\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "y"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
let (code, _, e) = run(&["merge", "--format=xml", "f"], &work, &xdg);
|
|
assert_ne!(code, 0, "unknown format value must error out");
|
|
assert!(e.contains("--format"), "error must mention --format: {e}");
|
|
}
|
|
|
|
#[test]
|
|
fn merge_local_toml_can_demote_handler() {
|
|
// Repo config pins `*.txt` to prose (rank 1); the user's local
|
|
// override demotes it to textual (rank 0). Merge should then use
|
|
// the textual handler — verify by checking the JSON report's
|
|
// handler field.
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("note.txt"), b"original\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("note.txt"), b"main side\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "main"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("note.txt"), b"feat side\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "feat"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
|
|
// Repo says: prose for *.txt. Local says: textual for *.txt (demote).
|
|
std::fs::write(
|
|
work.join(".levcs/merge.toml"),
|
|
b"schema_version = 1\n\n[[rule]]\nglob = \"*.txt\"\nhandler = \"prose\"\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
work.join(".levcs/merge.local.toml"),
|
|
b"schema_version = 1\n\n[[rule]]\nglob = \"*.txt\"\nhandler = \"textual\"\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let (code, stdout, _) = run(&["merge", "--format=json", "feat"], &work, &xdg);
|
|
// Conflict expected (both sides changed); the interesting bit is
|
|
// that the handler chosen is `textual`, not `prose`.
|
|
assert_ne!(code, 0);
|
|
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
|
|
let txt = v["files"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|f| f["path"] == "note.txt")
|
|
.expect("note.txt in report");
|
|
assert_eq!(
|
|
txt["handler"], "textual",
|
|
"demoted handler must be in effect"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_local_toml_promotion_is_rejected() {
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("note.txt"), b"x\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--create", "f"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("note.txt"), b"y\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "f"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
|
|
// Repo: textual. Local tries to promote to tree-sitter:rust (rank 2).
|
|
std::fs::write(
|
|
work.join(".levcs/merge.toml"),
|
|
b"schema_version = 1\n\n[[rule]]\nglob = \"*.txt\"\nhandler = \"textual\"\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
work.join(".levcs/merge.local.toml"),
|
|
b"schema_version = 1\n\n[[rule]]\nglob = \"*.txt\"\nhandler = \"tree-sitter:rust\"\n",
|
|
)
|
|
.unwrap();
|
|
let (code, _, e) = run(&["merge", "f"], &work, &xdg);
|
|
assert_ne!(code, 0, "promotion must error out");
|
|
assert!(
|
|
e.contains("merge.local.toml"),
|
|
"error must name the offending file: {e}"
|
|
);
|
|
assert!(e.contains("promote"), "error must say 'promote': {e}");
|
|
}
|
|
|
|
/// Regression test for the dirty-tree merge precondition. Before this
|
|
/// guard, `levcs merge` would silently overwrite uncommitted edits to
|
|
/// tracked files — clobbering work the user hadn't yet committed.
|
|
#[test]
|
|
fn merge_refuses_when_workdir_has_uncommitted_changes() {
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("a.txt"), b"original\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
|
|
|
|
// Create feat branch with a divergent change so a real merge would run.
|
|
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("b.txt"), b"feat side\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "feat add"], &work, &xdg).0, 0);
|
|
|
|
// Back on main, dirty `a.txt` *without committing*.
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("a.txt"), b"local-uncommitted-edit\n").unwrap();
|
|
|
|
// Merge must refuse and name the dirty file in the error.
|
|
let (code, _o, e) = run(&["merge", "feat"], &work, &xdg);
|
|
assert_ne!(code, 0, "merge must refuse on dirty tree: stderr={e}");
|
|
assert!(
|
|
e.contains("uncommitted changes") && e.contains("a.txt"),
|
|
"error must explain refusal and name the file: {e}"
|
|
);
|
|
|
|
// Critically: the local edit must NOT have been overwritten, and no
|
|
// merge state should have been created.
|
|
let bytes = std::fs::read(work.join("a.txt")).unwrap();
|
|
assert_eq!(
|
|
bytes, b"local-uncommitted-edit\n",
|
|
"user's uncommitted edit must be preserved when merge is refused"
|
|
);
|
|
assert!(!work.join(".levcs/MERGE_HEAD").exists());
|
|
assert!(!work.join(".levcs/merge-record").exists());
|
|
}
|
|
|
|
/// When the merge engine produces a record that violates the repo's
|
|
/// `[policy].allowed_handlers`, the merge must fail BEFORE touching the
|
|
/// working tree. Previously the policy check ran after `fs::write`, so
|
|
/// a rejected merge still left half-merged content on disk and stale
|
|
/// blob hashes in the index.
|
|
#[test]
|
|
fn policy_violation_rejects_merge_before_writing_working_tree() {
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("a.txt"), b"original\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
|
|
|
|
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("a.txt"), b"main side\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "main"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("a.txt"), b"feat side\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "feat"], &work, &xdg).0, 0);
|
|
|
|
// Switch to main and write a policy that forbids EVERYTHING — every
|
|
// file the engine touches will be flagged. (An empty allow-list with
|
|
// a non-empty handlers field on every record entry guarantees a
|
|
// mismatch; we want to verify that even though the engine produced
|
|
// a complete merge plan, the writes never landed.)
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
let main_a = std::fs::read(work.join("a.txt")).unwrap();
|
|
std::fs::write(
|
|
work.join(".levcs/merge.toml"),
|
|
b"[policy]\nallowed_handlers = [\"this-handler-does-not-exist\"]\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let (code, _, e) = run(&["merge", "feat"], &work, &xdg);
|
|
assert_ne!(code, 0, "merge must fail under restrictive policy");
|
|
assert!(
|
|
e.contains("not in repository policy"),
|
|
"error must explain policy mismatch: {e}"
|
|
);
|
|
|
|
// Working tree must be untouched: `a.txt` still holds main's content,
|
|
// not a partial merge result. And no merge state was committed.
|
|
let after = std::fs::read(work.join("a.txt")).unwrap();
|
|
assert_eq!(
|
|
after, main_a,
|
|
"policy-rejected merge must not modify the working tree"
|
|
);
|
|
assert!(
|
|
!work.join(".levcs/MERGE_HEAD").exists(),
|
|
"policy-rejected merge must not leave MERGE_HEAD behind"
|
|
);
|
|
assert!(
|
|
!work.join(".levcs/merge-record").exists(),
|
|
"policy-rejected merge must not persist a merge-record"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explain_dumps_merge_record() {
|
|
let (work, xdg) = init_repo();
|
|
std::fs::write(work.join("a.txt"), b"original\n").unwrap();
|
|
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("a.txt"), b"main version\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "main"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
|
|
std::fs::write(work.join("a.txt"), b"feat version\n").unwrap();
|
|
assert_eq!(run(&["commit", "-m", "feat"], &work, &xdg).0, 0);
|
|
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
|
|
let _ = run(&["merge", "feat"], &work, &xdg);
|
|
|
|
let (code, o, _) = run(&["merge", "--explain"], &work, &xdg);
|
|
assert_eq!(code, 0);
|
|
assert!(o.contains("schema_version"));
|
|
assert!(o.contains("a.txt"));
|
|
}
|