const MANAGED_PRIVATE: &str = "managedPrivate";
const MANAGED_PUBLIC: &str = "managedPublic";
#[test]
fn test_commit_msg_hook() {
let ctx = testutil::test_context_minimal!().build();
let msg_file = ctx.repo_path.join("COMMIT_EDITMSG");
std::fs::write(&msg_file, "feat: my cool feature").unwrap();
ctx.gherrit().args(["manage"]).assert().success();
ctx.gherrit()
.args(["hook", "commit-msg", msg_file.to_str().unwrap()])
.assert()
.success();
let content = std::fs::read_to_string(msg_file).unwrap();
assert!(content.contains("\ngherrit-pr-id: G"));
}
#[test]
fn test_full_stack_lifecycle_mocked() {
let ctx = testutil::test_context!().build();
ctx.checkout_new("feature-stack");
ctx.commit("Commit A");
ctx.commit("Commit B");
ctx.gherrit().args(["hook", "pre-push"]).assert().success();
if !ctx.is_live {
let state = ctx.read_mock_state();
assert_eq!(state.prs.len(), 2, "Expected 2 PRs created");
assert!(
!state.pushed_refs.is_empty(),
"Expected some refs to be pushed"
);
}
}
#[test]
fn test_branch_management() {
let ctx = testutil::test_context_minimal!()
.install_hooks(true)
.build();
ctx.checkout_new("feature-A");
ctx.run_git(&["config", "branch.feature-A.pushRemote", "origin"]);
ctx.gherrit().args(["manage"]).assert().success(); ctx.git()
.args(["config", "branch.feature-A.gherritManaged"])
.assert()
.failure();
ctx.gherrit().args(["manage", "--force"]).assert().success();
ctx.git()
.args(["config", "branch.feature-A.gherritManaged"])
.assert()
.success()
.stdout(format!("{}\n", MANAGED_PRIVATE));
ctx.git()
.args(["config", "branch.feature-A.pushRemote"])
.assert()
.success()
.stdout(".\n");
ctx.git()
.args(["config", "branch.feature-A.remote"])
.assert()
.success()
.stdout(".\n");
ctx.git()
.args(["config", "branch.feature-A.merge"])
.assert()
.success()
.stdout("refs/heads/feature-A\n");
ctx.gherrit().args(["unmanage"]).assert().success();
ctx.git()
.args(["config", "branch.feature-A.gherritManaged"])
.assert()
.success()
.stdout("false\n");
ctx.git()
.args(["config", "branch.feature-A.remote"])
.assert()
.failure();
ctx.git()
.args(["config", "branch.feature-A.merge"])
.assert()
.failure();
ctx.git()
.args(["config", "branch.feature-A.pushRemote"])
.assert()
.failure();
}
#[test]
fn test_post_checkout_hook() {
let ctx = testutil::test_context!().build();
ctx.checkout_new("feature-stack");
ctx.git()
.args(["config", "branch.feature-stack.gherritManaged"])
.assert()
.success()
.stdout(format!("{}\n", MANAGED_PRIVATE));
ctx.run_git(&["checkout", "main"]);
ctx.run_git(&["update-ref", "refs/remotes/origin/collab-feature", "HEAD"]);
ctx.run_git(&[
"checkout",
"-b",
"collab-feature",
"--track",
"origin/collab-feature",
]);
ctx.git()
.args(["config", "branch.collab-feature.gherritManaged"])
.assert()
.success()
.stdout("false\n");
}
#[test]
fn test_commit_msg_edge_cases() {
let ctx = testutil::test_context!().build();
ctx.gherrit().args(["manage"]).assert().success();
let squash_msg_file = ctx.repo_path.join("SQUASH_MSG");
let squash_content = "squash! some other commit";
std::fs::write(&squash_msg_file, squash_content).unwrap();
ctx.gherrit()
.args(["hook", "commit-msg", squash_msg_file.to_str().unwrap()])
.assert()
.success();
let content_after = std::fs::read_to_string(&squash_msg_file).unwrap();
assert_eq!(
content_after, squash_content,
"Commit-msg hook should ignore squash commits"
);
ctx.run_git(&["checkout", "--detach"]);
let detached_msg_file = ctx.repo_path.join("DETACHED_MSG");
let detached_content = "feat: detached work";
std::fs::write(&detached_msg_file, detached_content).unwrap();
ctx.gherrit()
.args(["hook", "commit-msg", detached_msg_file.to_str().unwrap()])
.assert()
.success();
let content_after = std::fs::read_to_string(&detached_msg_file).unwrap();
assert_eq!(
content_after, detached_content,
"Commit-msg hook should ignore detached HEAD"
);
}
#[test]
fn test_pre_push_ancestry_check() {
let ctx = testutil::test_context_minimal!()
.install_hooks(true)
.build();
ctx.commit("Initial Root");
ctx.run_git(&["checkout", "--orphan", "lonely-branch"]);
ctx.commit("Lonely Commit");
let output = ctx.gherrit().args(["hook", "pre-push"]).assert().failure();
let output = output.get_output();
let stderr = std::str::from_utf8(&output.stderr).unwrap();
assert!(
stderr.contains("not based on") || stderr.contains("share history"),
"Expected ancestry error, got: {}",
stderr
);
}
#[test]
fn test_version_increment() {
let ctx = testutil::test_context!().build();
ctx.checkout_new("feat-versioning");
ctx.commit("Feature Commit");
ctx.gherrit().args(["hook", "pre-push"]).assert().success();
let mut pushed_count_v1 = 0;
if !ctx.is_live {
let state = ctx.read_mock_state();
let has_v1 = state.pushed_refs.iter().any(|r| r.contains("/v1"));
assert!(
has_v1,
"Expected v1 tag to be pushed. Refs: {:?}",
state.pushed_refs
);
pushed_count_v1 = state.pushed_refs.len();
}
ctx.run_git(&["commit", "--amend", "--allow-empty", "--no-edit"]);
ctx.gherrit().args(["hook", "pre-push"]).assert().success();
if !ctx.is_live {
let state = ctx.read_mock_state();
let has_v2 = state.pushed_refs.iter().any(|r| r.contains("/v2"));
assert!(
has_v2,
"Expected v2 tag to be pushed. Refs: {:?}",
state.pushed_refs
);
let new_pushes = &state.pushed_refs[pushed_count_v1..];
let v1_repush = new_pushes.iter().any(|r| r.contains("/v1"));
assert!(
!v1_repush,
"v1 tag should NOT be pushed again in the second push. New pushes: {:?}",
new_pushes
);
let output = ctx.remote_git().args(["tag", "-l"]).output().unwrap();
let tags = std::str::from_utf8(&output.stdout).unwrap();
assert!(tags.contains("/v1"), "Remote should contain v1 tag");
assert!(tags.contains("/v2"), "Remote should contain v2 tag");
}
}
#[test]
fn test_optimistic_locking_conflict() {
let ctx = testutil::test_context!().build();
ctx.checkout_new("feature-conflict");
ctx.commit("Commit V1");
ctx.gherrit().args(["hook", "pre-push"]).assert().success();
let output = ctx
.git()
.args(["for-each-ref", "--format=%(refname:short)", "refs/gherrit/"])
.output()
.unwrap();
let stdout = std::str::from_utf8(&output.stdout).unwrap();
let gherrit_id = stdout
.lines()
.next()
.expect("No gherrit ref found")
.strip_prefix("gherrit/")
.expect("Invalid ref format");
let tag_name = format!("gherrit/{}/v2", gherrit_id);
ctx.remote_git()
.args(["tag", &tag_name, &format!("refs/heads/{}", gherrit_id)])
.assert()
.success();
let new_msg = format!("Commit V1 (Amended)\n\ngherrit-pr-id: {}", gherrit_id);
ctx.run_git(&["commit", "--amend", "--allow-empty", "-m", &new_msg]);
let output = ctx.gherrit().args(["hook", "pre-push"]).assert().failure();
let stderr = std::str::from_utf8(&output.get_output().stderr).unwrap();
assert!(
stderr.contains("`git push` failed"),
"Expected push failure due to lock, got: {}",
stderr
);
assert!(
stderr.contains("stale info") || stderr.contains("atomic push failed"),
"Expected atomic push failure (stale info), got: {}",
stderr
);
}
#[test]
fn test_pr_body_generation() {
let ctx = testutil::test_context!().build();
ctx.checkout_new("feature-stack");
ctx.commit("Commit A");
ctx.commit("Commit B");
ctx.commit("Commit C");
ctx.gherrit().args(["hook", "pre-push"]).assert().success();
if !ctx.is_live {
let state = ctx.read_mock_state();
assert_eq!(state.prs.len(), 3, "Expected 3 PRs");
let pr_b = state
.prs
.iter()
.find(|pr| pr.title == "Commit B")
.expect("PR for Commit B not found");
let body = &pr_b.body;
assert!(
body.contains("<!-- gherrit-meta: {"),
"Body missing gherrit-meta block"
);
assert!(
body.contains(r#""parent": "G"#),
"Body missing valid parent field"
);
assert!(
body.contains(r#""child": "G"#),
"Body missing valid child field"
);
assert!(
!body.contains("| Version |"),
"Table should NOT be present for v1"
);
}
ctx.run_git(&["checkout", "feature-stack"]);
ctx.run_git(&["commit", "--amend", "--allow-empty", "--no-edit"]);
ctx.gherrit().args(["hook", "pre-push"]).assert().success();
if !ctx.is_live {
let state = ctx.read_mock_state();
let pr_c = state
.prs
.iter()
.find(|pr| pr.title == "Commit C")
.expect("PR for Commit C not found");
let body = &pr_c.body;
assert!(
body.contains("| Version |"),
"Patch History Table should appear for v2"
);
assert!(body.contains("v1 |"), "Table should reference v1");
}
}
#[test]
fn test_large_stack_batching() {
let ctx = testutil::test_context!().build();
ctx.checkout_new("large-stack");
for i in 1..=85 {
ctx.commit(&format!("Commit {}", i));
}
ctx.gherrit().args(["hook", "pre-push"]).assert().success();
if !ctx.is_live {
let state = ctx.read_mock_state();
assert_eq!(state.prs.len(), 85, "Expected 85 PRs created");
let v1_refs = state
.pushed_refs
.iter()
.filter(|r| !r.starts_with("--"))
.filter(|r| r.contains("/v1"))
.count();
assert_eq!(
v1_refs,
85,
"Expected 85 v1 specific refs pushed. Total pushed: {:?}",
state.pushed_refs.len()
);
assert_eq!(
state.push_count, 2,
"Expected 2 push invocations for 85 commits (batch size 80)"
);
}
}
#[test]
fn test_rebase_detection() {
let ctx = testutil::test_context!().build();
ctx.checkout_new("feature-rebase");
ctx.commit("Feature Work");
ctx.run_git(&["checkout", "--detach"]);
let rebase_dir = ctx.repo_path.join(".git/rebase-merge");
std::fs::create_dir_all(&rebase_dir).unwrap();
std::fs::write(rebase_dir.join("head-name"), "refs/heads/feature-rebase").unwrap();
ctx.gherrit().args(["manage"]).assert().success();
ctx.git()
.args(["config", "branch.feature-rebase.gherritManaged"])
.assert()
.success()
.stdout(format!("{}\n", MANAGED_PRIVATE));
}
#[test]
fn test_public_stack_links() {
let ctx = testutil::test_context_minimal!()
.install_hooks(true)
.build();
ctx.commit("Init");
ctx.checkout_new("public-feature");
ctx.commit("Public Commit");
ctx.gherrit().args(["hook", "pre-push"]).assert().success();
if !ctx.is_live {
let state = ctx.read_mock_state();
let body = &state.prs[0].body;
assert!(
!body.contains("This PR is on branch"),
"Private stack should NOT link to local branch"
);
}
ctx.run_git(&["config", "branch.public-feature.pushRemote", "origin"]);
ctx.run_git(&["commit", "--amend", "--allow-empty", "--no-edit"]);
ctx.gherrit().args(["hook", "pre-push"]).assert().success();
if !ctx.is_live {
let state = ctx.read_mock_state();
let body = &state.prs[0].body; assert!(
body.contains("This PR is on branch"),
"Public stack SHOULD link to local branch"
);
assert!(
body.contains("[public-feature]"),
"Link should mention the branch name"
);
}
}
#[test]
fn test_install_command_edge_cases() {
let ctx = testutil::test_context_minimal!().build();
let hooks_dir = ctx.repo_path.join(".git/hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
let pre_push = hooks_dir.join("pre-push");
std::fs::write(&pre_push, "foo").unwrap();
ctx.gherrit()
.args(["install"])
.assert()
.failure() .stderr(predicates::str::contains("Refusing to overwrite"));
assert_eq!(std::fs::read_to_string(&pre_push).unwrap(), "foo");
ctx.gherrit()
.args(["install", "--force"])
.assert()
.success();
let content = std::fs::read_to_string(&pre_push).unwrap();
assert!(content.contains("# gherrit-installer: managed"));
ctx.gherrit().args(["install"]).assert().success();
let modified = content + "\n# Some custom comment";
std::fs::write(&pre_push, modified).unwrap();
ctx.gherrit()
.args(["install"]) .assert()
.success();
let reset_content = std::fs::read_to_string(&pre_push).unwrap();
assert!(reset_content.contains("# gherrit-installer: managed"));
assert!(!reset_content.contains("# Some custom comment"));
}
#[test]
fn test_install_configuration_and_security() {
let ctx = testutil::test_context_minimal!().build();
let default_hooks = ctx.repo_path.join(".git/hooks");
if default_hooks.exists() {
std::fs::remove_dir_all(&default_hooks).unwrap();
}
ctx.gherrit().args(["install"]).assert().success();
assert!(
default_hooks.join("pre-push").exists(),
"Should create directory and install hook"
);
let custom_internal = ctx.repo_path.join(".githooks");
ctx.run_git(&["config", "core.hooksPath", ".githooks"]);
ctx.gherrit().args(["install"]).assert().success();
assert!(
custom_internal.join("pre-push").exists(),
"Should respect core.hooksPath within repo"
);
let external_dir = tempfile::TempDir::new().unwrap();
let ext_path = external_dir.path().to_str().unwrap();
ctx.run_git(&["config", "core.hooksPath", ext_path]);
ctx.gherrit()
.args(["install"])
.assert()
.failure()
.stderr(predicates::str::contains("external/global hooks path"));
assert!(
!external_dir.path().join("pre-push").exists(),
"Should NOT install to external path without flag"
);
ctx.gherrit()
.args(["install", "--allow-global"])
.assert()
.success();
assert!(
external_dir.path().join("pre-push").exists(),
"Should install to external path with --allow-global"
);
}
#[test]
fn test_manage_detached_head() {
let ctx = testutil::test_context_minimal!().build();
ctx.commit("Init");
ctx.run_git(&["checkout", "--detach"]);
let test = |args: &[_]| {
ctx.gherrit()
.args(args)
.assert()
.failure()
.stderr(predicates::str::contains(
"Cannot get management state in detached HEAD",
))
};
test(&["manage"]);
test(&["manage", "--public"]);
test(&["manage", "--private"]);
test(&["unmanage"]);
}
#[test]
fn test_unmanage_cleanup_logic() {
let ctx = testutil::test_context_minimal!().build();
ctx.commit("Init");
ctx.checkout_new("feature-cleanup");
ctx.run_git(&[
"config",
"branch.feature-cleanup.gherritManaged",
MANAGED_PRIVATE,
]);
ctx.run_git(&["config", "branch.feature-cleanup.pushRemote", "."]);
ctx.run_git(&["config", "branch.feature-cleanup.remote", "."]);
ctx.run_git(&[
"config",
"branch.feature-cleanup.merge",
"refs/heads/feature-cleanup",
]);
ctx.gherrit().args(["unmanage"]).assert().success();
ctx.git()
.args(["config", "branch.feature-cleanup.remote"])
.assert()
.failure();
ctx.git()
.args(["config", "branch.feature-cleanup.merge"])
.assert()
.failure();
ctx.git()
.args(["config", "branch.feature-cleanup.gherritManaged"])
.assert()
.success()
.stdout("false\n");
}
#[test]
fn test_pre_push_failure() {
let ctx = testutil::test_context_minimal!()
.install_hooks(true)
.build();
ctx.commit("Init");
ctx.checkout_new("feature-fail");
ctx.commit("Work to push");
ctx.run_git(&["remote", "add", "broken-remote", "/path/to/nowhere"]);
ctx.run_git(&["config", "gherrit.remote", "broken-remote"]);
ctx.gherrit()
.args(["hook", "pre-push"])
.assert()
.failure()
.stderr(predicates::str::contains("`git push` failed"));
}
#[test]
#[cfg(unix)]
fn test_install_read_only_fs() {
use std::os::unix::fs::PermissionsExt as _;
if unsafe { libc::geteuid() } == 0 {
return;
}
let ctx = testutil::test_context_minimal!().build();
let hooks_dir = ctx.repo_path.join(".git/hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
let mut perms = std::fs::metadata(&hooks_dir).unwrap().permissions();
perms.set_mode(0o555); std::fs::set_permissions(&hooks_dir, perms).unwrap();
ctx.gherrit().args(["install"]).assert().failure();
let mut perms = std::fs::metadata(&hooks_dir).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hooks_dir, perms).unwrap();
}
#[test]
fn test_manage_drift_detection() {
let ctx = testutil::test_context_minimal!().build();
ctx.checkout_new("drift-feature");
ctx.gherrit()
.args(["manage", "--private"])
.assert()
.success();
ctx.run_git(&["config", "branch.drift-feature.pushRemote", "origin"]);
let output = ctx
.gherrit()
.args(["manage", "--public"])
.assert()
.success();
let stderr = std::str::from_utf8(&output.get_output().stderr).unwrap();
assert!(stderr.contains("Configuration drift detected"));
assert!(stderr.contains("- pushRemote: current='origin', expected='.'"));
ctx.git()
.args(["config", "branch.drift-feature.gherritManaged"])
.assert()
.stdout(format!("{}\n", MANAGED_PRIVATE));
ctx.gherrit()
.args(["manage", "--public", "--force"])
.assert()
.success();
ctx.git()
.args(["config", "branch.drift-feature.gherritManaged"])
.assert()
.stdout(format!("{}\n", MANAGED_PUBLIC));
ctx.git()
.args(["config", "branch.drift-feature.pushRemote"])
.assert()
.stdout("origin\n");
}
#[test]
fn test_manage_toggle_visibility() {
let ctx = testutil::test_context_minimal!().build();
ctx.checkout_new("visibility-feature");
ctx.gherrit()
.args(["manage", "--private"])
.assert()
.success();
ctx.git()
.args(["config", "branch.visibility-feature.pushRemote"])
.assert()
.stdout(".\n");
ctx.gherrit()
.args(["manage", "--public"])
.assert()
.success();
ctx.git()
.args(["config", "branch.visibility-feature.pushRemote"])
.assert()
.stdout("origin\n");
ctx.gherrit()
.args(["manage", "--private"])
.assert()
.success();
ctx.git()
.args(["config", "branch.visibility-feature.pushRemote"])
.assert()
.stdout(".\n");
}
#[test]
fn test_manage_mutually_exclusive_flags() {
let ctx = testutil::test_context_minimal!().build();
ctx.checkout_new("conflict-feature");
let assert = ctx
.gherrit()
.args(["manage", "--public", "--private"])
.assert()
.failure();
let output = assert.get_output();
let stderr = std::str::from_utf8(&output.stderr).unwrap();
assert!(stderr.contains("the argument '--public' cannot be used with '--private'"));
}
#[test]
fn test_manage_invalid_config() {
let ctx = testutil::test_context_minimal!().build();
ctx.checkout_new("invalid-config-feature");
ctx.run_git(&[
"config",
"branch.invalid-config-feature.gherritManaged",
"bad-value",
]);
let assert = ctx.gherrit().args(["manage"]).assert().failure();
let output = assert.get_output();
let stderr = std::str::from_utf8(&output.stderr).unwrap();
assert!(stderr.contains("Invalid gherritManaged value"));
assert!(stderr.contains("bad-value"));
}
#[test]
fn test_post_checkout_drift_detection() {
let ctx = testutil::test_context!().build();
ctx.run_git(&["checkout", "main"]);
ctx.run_git(&["update-ref", "refs/remotes/origin/drift-shared", "HEAD"]);
let assert = ctx
.git()
.args([
"checkout",
"-b",
"drift-shared",
"--track",
"origin/drift-shared",
])
.assert()
.success();
let stderr = std::str::from_utf8(&assert.get_output().stderr).unwrap();
assert!(
!stderr.contains("Configuration drift detected"),
"Expected NO drift warning for shared branch (silent unmanage)"
);
ctx.run_git(&["checkout", "main"]);
ctx.run_git(&["branch", "drift-stack"]);
ctx.run_git(&["config", "branch.drift-stack.remote", "origin"]);
let assert = ctx
.git()
.args(["checkout", "drift-stack"])
.assert()
.success();
let stderr = std::str::from_utf8(&assert.get_output().stderr).unwrap();
assert!(
stderr.contains("Configuration drift detected"),
"Expected drift warning for new stack"
);
assert!(stderr.contains("- remote: current='origin', expected='<unset>'"));
}