#![cfg(unix)]
use super::{plan_management, retired_explanation};
use crate::action::Action;
use crate::config_file::CONFIG_FILE_NAME;
use crate::management_arguments::{MANAGEMENT_HELP, MANAGEMENT_USAGE, RetiredCommand};
use crate::policy_registry::PolicyId;
use crate::real_git::{Platform, ResolutionInputs};
use crate::test_support::{executable, fixture, remove, repository};
use crate::wrapper_controls::{Controls, no_controls};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
fn no_git(root: &Path) -> ResolutionInputs {
return ResolutionInputs {
platform: Platform::Unix,
path: OsString::from("empty-directory"),
path_extensions: OsString::new(),
current_directory: root.to_path_buf(),
common_paths: Vec::<PathBuf>::new(),
own_executable: root.join("own-wrapper"),
};
}
fn with_git(root: &Path) -> ResolutionInputs {
let own: PathBuf = root.join("own-wrapper");
executable(own.as_path(), b"\x7fELF fixture wrapper");
let mut inputs: ResolutionInputs = no_git(root);
inputs.path = OsString::from("/usr/bin");
inputs.own_executable = own;
return inputs;
}
fn plan_with(
values: &[&str],
directory: &Path,
controls: &Controls,
inputs: &ResolutionInputs,
) -> Action {
let mut arguments: Vec<OsString> = Vec::<OsString>::new();
for value in values {
arguments.push(OsString::from(value));
}
let global: Vec<OsString> = vec![OsString::from("-C"), directory.as_os_str().to_os_string()];
return plan_management(
arguments.as_slice(),
global.as_slice(),
controls,
&[],
inputs,
);
}
fn plan(values: &[&str], directory: &Path, inputs: &ResolutionInputs) -> Action {
return plan_with(values, directory, &no_controls(), inputs);
}
fn exit(code: i32, stdout: &str, stderr: &str) -> Action {
return Action::Exit {
code,
stdout: String::from(stdout),
stderr: String::from(stderr),
};
}
const FIXED_A: &str = "{\"schemaVersion\":1,\"sequence\":0,\"type\":\"fix-summary\",\"trigger\":\"direct-fix\",\"passes\":1,\"changedPaths\":[\"a.txt\"]}\n";
fn final_newline_warning(sequence: u64) -> String {
return format!(
"{{\"schemaVersion\":1,\"sequence\":{sequence},\"type\":\"finding\",\"trigger\":\"direct-check\",\"policyId\":\"final-newline\",\"severity\":\"warn\",\"code\":\"final-newline/noncanonical-final-newline\",\"message\":\"Non-empty text file must end with exactly one LF byte.\",\"path\":\"a.txt\",\"fix\":\"none\"}}\n"
);
}
fn projection_failed(action: Action, trigger: &str, message: &str) {
match action {
Action::Exit {
code,
stdout,
stderr,
} => {
assert_eq!(code, 2);
assert_eq!(stderr, "");
let head: String = format!(
"{{\"schemaVersion\":1,\"sequence\":0,\"type\":\"engine-failure\",\"code\":\"transaction-failed\",\"message\":\"{message}"
);
assert!(stdout.starts_with(head.as_str()), "{stdout}");
assert!(
stdout.ends_with(format!("\",\"trigger\":\"{trigger}\"}}\n").as_str()),
"{stdout}"
);
assert_eq!(stdout.matches('\n').count(), 1, "{stdout}");
}
Action::Forward { .. } => panic!("a direct command is never forwarded"),
Action::ForwardCoordinated { .. } => panic!("a coordinated forward is not expected in this control"),
Action::ForwardSettled { .. } => panic!("a settled forward is not expected in this control"),
}
}
fn not_at_root(sequence: u64, repo: &Path) -> String {
return format!(
"{{\"schemaVersion\":1,\"sequence\":{sequence},\"type\":\"finding\",\"trigger\":\"direct-check\",\"policyId\":\"require-root\",\"severity\":\"error\",\"code\":\"require-root/not-at-root\",\"message\":\"cli-git: not at the root of the git repository. Repo root is {root} but effective cwd is {root}/nested. Tip: cd to {root} or pass -C {root} before the subcommand.\",\"fix\":\"none\"}}\n",
root = repo.display()
);
}
#[test]
fn help_and_refusals_need_no_git() {
let root: PathBuf = fixture("management-no-git");
let inputs: ResolutionInputs = no_git(root.as_path());
assert_eq!(
plan(&["--help"], root.as_path(), &inputs),
exit(0, MANAGEMENT_HELP, "")
);
assert_eq!(
plan(&["-h"], root.as_path(), &inputs),
exit(0, MANAGEMENT_HELP, "")
);
for arguments in [
vec![],
vec!["unknown"],
vec!["check", "--bogus"],
vec!["--help", "x"],
] {
assert_eq!(
plan(arguments.as_slice(), root.as_path(), &inputs),
exit(2, "", MANAGEMENT_USAGE),
"{arguments:?}"
);
}
assert_eq!(
plan(&["check", "a.txt"], root.as_path(), &inputs),
exit(2, "", "git cli-git check pathspecs must follow --.\n")
);
assert_eq!(
plan(&["fix", "a.txt"], root.as_path(), &inputs),
exit(2, "", "git cli-git fix pathspecs must follow --.\n")
);
assert_eq!(
plan(&["check"], root.as_path(), &inputs),
exit(
2,
"",
"git cli-git check requires exactly one scope: --all or non-empty pathspecs after --.\n"
)
);
assert_eq!(
plan(&["fix", "--all", "--", "a.txt"], root.as_path(), &inputs),
exit(
2,
"",
"git cli-git fix requires exactly one scope: --all or non-empty pathspecs after --.\n"
)
);
remove(root.as_path());
}
#[test]
fn retired_commands_explain_and_succeed() {
let root: PathBuf = fixture("management-retired");
let inputs: ResolutionInputs = no_git(root.as_path());
for (arguments, name, command) in [
(vec!["trust"], "trust", RetiredCommand::Trust),
(vec!["trust", "--yes"], "trust", RetiredCommand::Trust),
(vec!["untrust"], "untrust", RetiredCommand::Untrust),
(vec!["status"], "status", RetiredCommand::Status),
] {
let explanation: String = format!(
"git cli-git {name} is retired. cli-git now reads cli-git.config.jsonc as data and \
runs no repository-supplied code, so there is no code execution to approve, revoke \
or report. Nothing was changed; existing trust records are left in place and are no \
longer read.\n"
);
assert_eq!(retired_explanation(command), explanation);
assert_eq!(
plan(arguments.as_slice(), root.as_path(), &inputs),
exit(0, "", explanation.as_str()),
"{arguments:?}"
);
}
assert_eq!(
plan(&["trust", "--help"], root.as_path(), &inputs),
exit(0, retired_explanation(RetiredCommand::Trust).as_str(), "")
);
remove(root.as_path());
}
#[test]
fn unknown_selected_policy_is_reported_on_stdout() {
let root: PathBuf = fixture("management-unknown-policy");
let inputs: ResolutionInputs = no_git(root.as_path());
for (arguments, id) in [
(
vec![
"check",
"--all",
"--policy",
"final-newline",
"--policy",
"nope",
],
"nope",
),
(
vec!["fix", "--policy=security/forbidden-string", "--", "a"],
"security/forbidden-string",
),
(vec!["check", "--all", "--policy="], ""),
(vec!["check", "--all", "--policy", "a\"b"], "a\\\"b"),
] {
assert_eq!(
plan(arguments.as_slice(), root.as_path(), &inputs),
exit(
2,
format!(
"{{\"schemaVersion\":1,\"sequence\":0,\"type\":\"engine-failure\",\"code\":\"config-invalid\",\"message\":\"Unknown policy ID: {id}. Shipped policies: require-root, linked-worktree-only, branch-worktree-only, add-explicit, final-newline, markdown/autofix, mono/forbidden-root-context, mono/dependent-version-bump, security/forbidden-strings.\"}}\n"
)
.as_str(),
""
),
"{arguments:?}"
);
}
remove(root.as_path());
}
#[test]
fn direct_commands_project_their_scope_and_run_the_policies() {
let root: PathBuf = fixture("management-direct");
let repo: PathBuf = repository(root.as_path(), "repo");
let nested: PathBuf = repo.join("nested");
std::fs::create_dir(&nested).expect("nested directory");
std::fs::write(repo.join("a.txt"), b"no final newline").expect("file");
let index_before: Vec<u8> = std::fs::read(repo.join(".git/index")).expect("index");
let inputs: ResolutionInputs = with_git(root.as_path());
assert_eq!(
plan(&["check", "--all"], repo.as_path(), &inputs),
exit(0, final_newline_warning(0).as_str(), "")
);
assert_eq!(
plan(&["check", "--", "a.txt"], repo.as_path(), &inputs),
exit(0, final_newline_warning(0).as_str(), "")
);
assert_eq!(
plan(
&[
"fix",
"--policy",
"final-newline",
"--policy",
"security/forbidden-strings",
"--",
"a.txt"
],
repo.as_path(),
&inputs
),
exit(0, FIXED_A, "")
);
assert_eq!(
std::fs::read(repo.join("a.txt")).expect("fixed"),
b"no final newline\n"
);
assert_eq!(
plan(&["fix", "--", "a.txt"], repo.as_path(), &inputs),
exit(0, "", "")
);
std::fs::write(repo.join("a.txt"), b"no final newline").expect("file again");
projection_failed(
plan(
&["check", "--policy", "require-root", "--", "missing.txt"],
repo.as_path(),
&inputs,
),
"direct-check",
"cli-git could not read the selected worktree files: git add failed: fatal: pathspec",
);
projection_failed(
plan(&["fix", "--", "missing.txt"], repo.as_path(), &inputs),
"direct-fix",
"cli-git could not read the selected worktree files: git add failed: fatal: pathspec",
);
projection_failed(
plan(&["check", "--all"], root.as_path(), &inputs),
"direct-check",
"cli-git could not read the selected worktree files: git rev-parse failed:",
);
for arguments in [
vec!["check", "--all", "--policy", "require-root"],
vec!["check", "--policy=require-root", "--", "a.txt"],
vec!["check", "--all", "--policy", "linked-worktree-only"],
vec!["fix", "--all", "--policy", "require-root"],
vec!["fix", "--all", "--policy", "add-explicit"],
] {
assert_eq!(
plan(arguments.as_slice(), repo.as_path(), &inputs),
exit(0, "", ""),
"{arguments:?}"
);
}
assert_eq!(
plan(
&["check", "--all", "--policy", "require-root"],
nested.as_path(),
&inputs
),
exit(1, not_at_root(0, repo.as_path()).as_str(), "")
);
assert_eq!(
plan(
&["fix", "--all", "--policy", "require-root"],
nested.as_path(),
&inputs
),
exit(0, "", "")
);
assert_eq!(
std::fs::read(repo.join(".git/index")).expect("index"),
index_before,
"a direct command never changes the index"
);
remove(root.as_path());
}
#[test]
fn controls_before_the_namespace_reach_a_direct_command() {
let root: PathBuf = fixture("management-controls");
let repo: PathBuf = repository(root.as_path(), "repo");
let nested: PathBuf = repo.join("nested");
std::fs::create_dir(&nested).expect("nested directory");
let inputs: ResolutionInputs = with_git(root.as_path());
assert_eq!(
plan(&["check", "--all"], nested.as_path(), &inputs),
exit(1, not_at_root(0, repo.as_path()).as_str(), "")
);
std::fs::write(repo.join("a.txt"), b"no final newline").expect("file");
let mut keep_going: Controls = no_controls();
keep_going.keep_going = true;
assert_eq!(
plan_with(&["check", "--all"], nested.as_path(), &keep_going, &inputs),
exit(
1,
format!(
"{}{}",
not_at_root(0, repo.as_path()),
final_newline_warning(1)
)
.as_str(),
""
)
);
let mut escaped: Controls = no_controls();
escaped.escaped.push(PolicyId::RequireRoot);
assert_eq!(
plan_with(
&["check", "--all", "--policy", "require-root"],
nested.as_path(),
&escaped,
&inputs
),
exit(0, "", "")
);
remove(root.as_path());
}
#[test]
fn direct_commands_use_the_repository_configuration() {
let root: PathBuf = fixture("management-configured");
let repo: PathBuf = repository(root.as_path(), "repo");
let nested: PathBuf = repo.join("nested");
std::fs::create_dir(&nested).expect("nested directory");
let inputs: ResolutionInputs = with_git(root.as_path());
let source: PathBuf = repo.join(CONFIG_FILE_NAME);
std::fs::write(&source, r#"{ "trust": { "children": true } }"#).expect("invalid config");
assert_eq!(
plan(&["check", "--all"], repo.as_path(), &inputs),
exit(
2,
format!(
"{{\"schemaVersion\":1,\"sequence\":0,\"type\":\"engine-failure\",\"code\":\"config-invalid\",\"message\":\"{}: Configuration key trust is retired: JSONC configuration is data and needs no code-execution approval. Remove the key.\"}}\n",
source.display()
)
.as_str(),
""
)
);
std::fs::write(&source, r#"{ "policies": { "final-newline": "off" } }"#).expect("valid config");
assert_eq!(
plan(&["check", "--all"], repo.as_path(), &inputs),
exit(0, "", "")
);
assert_eq!(
plan(&["fix", "--all"], repo.as_path(), &inputs),
exit(0, "", "")
);
std::fs::write(
&source,
"{ \"policies\": { \"final-newline\": \"off\", \"mono/forbidden-root-context\": \"error\" } }\n",
)
.expect("valid config");
assert_eq!(
plan(&["check", "--all"], repo.as_path(), &inputs),
exit(0, "", "")
);
std::fs::write(repo.join("CONTEXT.md"), b"context\n").expect("context");
assert_eq!(
plan(&["check", "--all"], repo.as_path(), &inputs),
exit(
1,
"{\"schemaVersion\":1,\"sequence\":0,\"type\":\"finding\",\"trigger\":\"direct-check\",\"policyId\":\"mono/forbidden-root-context\",\"severity\":\"error\",\"code\":\"mono/forbidden-root-context/root-context-forbidden\",\"message\":\"Root CONTEXT.md is forbidden; read source code directly.\",\"path\":\"CONTEXT.md\",\"fix\":\"none\"}\n",
""
)
);
assert_eq!(
plan(&["fix", "--all"], repo.as_path(), &inputs),
exit(0, "", "")
);
std::fs::write(&source, r#"{ "policies": { "final-newline": "off" } }"#).expect("valid config");
std::fs::write(repo.join("cli-git.config.ts"), "export default {};").expect("legacy config");
let legacy_warning: String = format!(
"{{\"schemaVersion\":1,\"sequence\":0,\"type\":\"configuration-warning\",\"code\":\"legacy-config-ignored\",\"message\":\"Legacy configuration {legacy} is ignored: {jsonc} is authoritative for the native cli-git. Remove the legacy file once no TypeScript cli-git reads it.\",\"path\":\"{legacy}\"}}\n",
legacy = repo.join("cli-git.config.ts").display(),
jsonc = source.display()
);
assert_eq!(
plan(&["check", "--all"], repo.as_path(), &inputs),
exit(0, legacy_warning.as_str(), "")
);
assert_eq!(
plan(&["check", "--all"], nested.as_path(), &inputs),
exit(
1,
format!("{legacy_warning}{}", not_at_root(1, repo.as_path())).as_str(),
""
)
);
assert_eq!(
plan(&["fix", "--all"], repo.as_path(), &inputs),
exit(0, "", "")
);
remove(root.as_path());
}
#[test]
fn direct_commands_refuse_beside_registered_transactions() {
let root: PathBuf = fixture("management-pending");
let repo: PathBuf = repository(root.as_path(), "repo");
let inputs: ResolutionInputs = with_git(root.as_path());
let registry: PathBuf = repo.join(".git/cli-git-transactions");
crate::transaction_registry::ensure_transaction_root(registry.as_path()).expect("registry");
let id: &str = "0b6c2c1e-6f5b-4d0e-9a55-3f5d8e2f6a10";
crate::transaction_registry::publish_transaction_directory(
registry.as_path(),
id,
crate::recovery_inspect::tests::owner_bytes(id, "2026-10-06T00:00:00.000Z", true)
.as_slice(),
)
.expect("live transaction");
std::fs::create_dir_all(repo.join(".git/cli-git-worktree-copy/v1")).expect("journals");
std::fs::write(repo.join(".git/cli-git-worktree-copy/v1/id.json"), b"{}").expect("journal");
for (arguments, command) in [
(
vec!["check", "--all", "--policy", "require-root"],
"cli-git check",
),
(
vec!["fix", "--all", "--policy", "require-root"],
"cli-git fix",
),
] {
let Action::Exit { stderr, .. } = plan(arguments.as_slice(), repo.as_path(), &inputs) else {
panic!("{command} is answered by the wrapper, never forwarded");
};
assert!(
!stderr.contains("recovering or waiting for the commit transactions"),
"{command}: {stderr}"
);
}
std::fs::write(
registry.join(id).join("owner.json"),
crate::recovery_inspect::tests::owner_bytes(id, "2026-10-06T00:00:00.000Z", false),
)
.expect("dead owner");
assert_eq!(
plan(
&["check", "--all", "--policy", "require-root"],
repo.as_path(),
&inputs
),
exit(0, "", "")
);
assert!(!registry.join(id).exists());
std::fs::create_dir(registry.join("stray")).expect("stray entry");
match plan(
&["check", "--all", "--policy", "require-root"],
repo.as_path(),
&inputs,
) {
Action::Exit { code, stderr, .. } => {
assert_eq!(code, 2);
assert!(
stderr.contains("Unexpected transaction registry entry"),
"{stderr}"
);
}
Action::Forward { .. } => panic!("a direct command never forwards"),
Action::ForwardCoordinated { .. } => panic!("a coordinated forward is not expected in this control"),
Action::ForwardSettled { .. } => panic!("a settled forward is not expected in this control"),
}
remove(root.as_path());
}
#[test]
fn direct_commands_report_git_failures() {
let root: PathBuf = fixture("management-git-failure");
assert_eq!(
plan(&["check", "--all"], root.as_path(), &no_git(root.as_path())),
exit(
2,
"",
"cli-git: Could not find a real Git executable after examining 1 PATH candidates and \
skipping 0 cli-git wrappers. Ensure Git is installed and PATH/PATHEXT expose its \
executable.\n"
)
);
let mut fake: ResolutionInputs = with_git(root.as_path());
std::fs::create_dir(root.join("fake")).expect("directory");
executable(
root.join("fake/git").as_path(),
b"#!/bin/sh\nprintf 'false\\n/only-two-lines\\n'\nexit 0\n",
);
fake.path = OsString::from("fake");
match plan(&["check", "--all"], root.as_path(), &fake) {
Action::Exit {
code,
stdout,
stderr,
} => {
assert_eq!(code, 2);
assert_eq!(stdout, "");
assert!(
stderr.starts_with("cli-git: cli-git could not classify the Git worktree"),
"{stderr}"
);
}
Action::Forward { .. } => panic!("a direct command is never forwarded"),
Action::ForwardCoordinated { .. } => panic!("a coordinated forward is not expected in this control"),
Action::ForwardSettled { .. } => panic!("a settled forward is not expected in this control"),
}
remove(root.as_path());
}