monochromatic-git-policy-cli 0.5.4

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
Documentation
//! Controls for post-landing completion: the step order, the failure semantics of each step,
//! and the maintenance exemption from failing the landed commit.

use super::*;
use crate::recovery_landing::tests::Scene;
use crate::test_support::{executable, git_context, git_text};
use std::ffi::OsString;

/// Writes a stand-in Git that logs each invocation, answers `config` with `config_exit`, and otherwise runs real Git.
fn logging_git(root: &std::path::Path, config_exit: u8) -> (PathBuf, PathBuf) {
    let log: PathBuf = root.join("calls.log");
    let path: PathBuf = root.join("git");
    let config_line: String = if config_exit == 0 {
        String::new()
    } else {
        format!("case \" $* \" in *\" config \"*) exit {config_exit};; esac\n")
    };
    let content: String = format!(
        "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n{config_line}exec {} \"$@\"\n",
        log.display(),
        crate::test_support::REAL_GIT,
    );
    executable(path.as_path(), content.as_bytes());
    return (path, log);
}

/// A landed transaction over a scene, with `HEAD` as the landed commit.
fn landed(scene: &Scene, records: Vec<AddedPath>) -> LandedTransaction {
    return LandedTransaction {
        landed_oid: git_text(scene.repo.as_path(), &["rev-parse", "HEAD"]),
        branch: String::from("main"),
        cwd: scene.repo.clone(),
        repository_root: scene.repo.clone(),
        directory: scene.directory.clone(),
        shadow: scene.shadow.clone(),
        git_dir: scene.git_dir.clone(),
        registry_root: scene.registry.clone(),
        added_paths: Vec::new(),
        worktree_records: records,
        config_snapshot: None,
    };
}

/// The command names of the logged invocations, in order, so the test asserts the sequence.
fn sequence(log: &std::path::Path) -> Vec<String> {
    return std::fs::read_to_string(log)
        .unwrap_or_default()
        .lines()
        .map(String::from)
        .collect();
}

/// Position of the first logged invocation containing `needle`.
fn position(lines: &[String], needle: &str) -> Option<usize> {
    return lines.iter().position(|line| return line.contains(needle));
}

/// Every step runs in the incumbent's order: copies, disposal, maintenance; no author is read and no hook runner starts.
#[test]
fn completion_disposes_runs_maintenance_and_starts_no_hook_runner() {
    let scene: Scene = Scene::new("post-landing-order", false);
    std::fs::create_dir_all(scene.shadow.join("objects")).expect("shadow");
    let (stand_in, log): (PathBuf, PathBuf) = logging_git(scene.root.as_path(), 0);
    let mut context = git_context();
    context.real_git = stand_in;
    context.global_prefix = vec![
        OsString::from("-c"),
        OsString::from("maintenance.autodetach=false"),
    ];
    let transaction: LandedTransaction = landed(&scene, Vec::new());
    let outcome: PostLandingOutcome = complete_landed_transaction(PostLandingRequest {
        context: &context,
        landed: &transaction,
    })
    .expect("completed");
    assert!(!scene.shadow.exists());
    assert!(!scene.directory.exists());
    assert_eq!(outcome.landed_oid, transaction.landed_oid);
    assert_eq!(outcome.maintenance, Some(AutoMaintenanceOutcome::Exited(0)));
    let lines: Vec<String> = sequence(log.as_path());
    let maintenance: usize =
        position(lines.as_slice(), "maintenance run --auto").expect("maintenance started");
    assert!(position(lines.as_slice(), "show --no-patch").is_none(), "no author is read: {lines:?}");
    assert!(position(lines.as_slice(), "hook").is_none(), "no hook runner starts: {lines:?}");
    let _ = maintenance;
    scene.finish();
}

/// A copy that cannot be completed stops the sequence: the shadow and the directory stay for recovery,
/// and neither maintenance nor an action runs.
#[test]
fn an_install_failure_keeps_the_shadow_and_the_directory() {
    let scene: Scene = Scene::new("post-landing-install-fails", false);
    std::fs::create_dir_all(scene.shadow.join("objects")).expect("shadow");
    let (stand_in, log): (PathBuf, PathBuf) = logging_git(scene.root.as_path(), 0);
    let mut context = git_context();
    context.real_git = stand_in;
    let missing: String = String::from("1111111111111111111111111111111111111111");
    let records: Vec<AddedPath> = vec![AddedPath {
        path: b"added.txt".to_vec(),
        git_mode: String::from("100644"),
        original_oid: missing.clone(),
        intended_oid: missing,
    }];
    let transaction: LandedTransaction = landed(&scene, records);
    let failure: PostLandingError = complete_landed_transaction(PostLandingRequest {
        context: &context,
        landed: &transaction,
    })
    .expect_err("install failure");
    assert!(
        matches!(failure, PostLandingError::Install(_)),
        "{failure:?}"
    );
    assert!(scene.shadow.exists());
    assert!(scene.directory.exists());
    let lines: Vec<String> = sequence(log.as_path());
    assert!(
        position(lines.as_slice(), "maintenance run").is_none(),
        "{lines:?}"
    );
    scene.finish();
}

/// A maintenance config read that fails is logged and never fails the landed commit; the actions still run.
#[test]
fn a_maintenance_failure_never_fails_the_landed_commit() {
    let scene: Scene = Scene::new("post-landing-maintenance-fails", false);
    std::fs::create_dir_all(scene.shadow.join("objects")).expect("shadow");
    let (stand_in, log): (PathBuf, PathBuf) = logging_git(scene.root.as_path(), 2);
    let mut context = git_context();
    context.real_git = stand_in;
    let transaction: LandedTransaction = landed(&scene, Vec::new());
    let outcome: PostLandingOutcome = complete_landed_transaction(PostLandingRequest {
        context: &context,
        landed: &transaction,
    })
    .expect("completed despite maintenance");
    assert_eq!(outcome.maintenance, Some(AutoMaintenanceOutcome::Exited(2)));
    assert!(position(sequence(log.as_path()).as_slice(), "hook").is_none(), "no hook runner starts");
    scene.finish();
}

/// Writes a blob with `content` into the real object store, then moves its loose file into the shadow's object store,
/// so only `shadow/objects` can serve it, as it does for an object a transaction wrote before landing.
fn blob_only_in_shadow(scene: &Scene, content: &str) -> String {
    let source: PathBuf = scene.root.join("blob-source");
    std::fs::write(source.as_path(), content).expect("blob source");
    let oid: String = git_text(
        scene.repo.as_path(),
        &[
            "hash-object",
            "-w",
            "--",
            source.to_str().expect("UTF-8 path"),
        ],
    );
    let (fan_out, rest): (&str, &str) = oid.split_at(2);
    let from: PathBuf = scene.git_dir.join("objects").join(fan_out).join(rest);
    let to_directory: PathBuf = scene.shadow.join("objects").join(fan_out);
    std::fs::create_dir_all(to_directory.as_path()).expect("shadow fan-out");
    std::fs::rename(from, to_directory.join(rest)).expect("moved loose object");
    return oid;
}

/// A record whose worktree copy holds `original` and whose landed bytes are `intended`, both served by the shadow only.
fn shadow_record(scene: &Scene, path: &str, original: &str, intended: &str) -> AddedPath {
    std::fs::write(scene.repo.join(path), original).expect("worktree copy");
    return AddedPath {
        path: path.as_bytes().to_vec(),
        git_mode: String::from("100644"),
        original_oid: blob_only_in_shadow(scene, original),
        intended_oid: blob_only_in_shadow(scene, intended),
    };
}

/// Both record lists are completed, the added paths and the worktree records, from the shadow's objects,
/// and the shadow is removed only afterwards.
#[test]
fn both_record_lists_are_copied_from_the_shadow_before_it_is_removed() {
    let scene: Scene = Scene::new("post-landing-copies", false);
    let (stand_in, _log): (PathBuf, PathBuf) = logging_git(scene.root.as_path(), 0);
    let mut context = git_context();
    context.real_git = stand_in;
    let mut transaction: LandedTransaction = landed(&scene, Vec::new());
    transaction.added_paths = vec![shadow_record(
        &scene,
        "added.txt",
        "added before\n",
        "added after\n",
    )];
    transaction.worktree_records = vec![shadow_record(
        &scene,
        "fixed.txt",
        "fixed before\n",
        "fixed after\n",
    )];
    let outcome: PostLandingOutcome = complete_landed_transaction(PostLandingRequest {
        context: &context,
        landed: &transaction,
    })
    .expect("completed");
    assert_eq!(
        outcome.worktree.rewritten,
        vec![b"added.txt".to_vec(), b"fixed.txt".to_vec()]
    );
    assert!(outcome.worktree.conflicted.is_empty());
    assert_eq!(
        std::fs::read_to_string(scene.repo.join("added.txt")).expect("added copy"),
        "added after\n"
    );
    assert_eq!(
        std::fs::read_to_string(scene.repo.join("fixed.txt")).expect("fixed copy"),
        "fixed after\n"
    );
    assert!(!scene.shadow.exists());
    scene.finish();
}

/// A copy the user changed meanwhile is kept and reported, and the completion still disposes.
#[test]
fn a_concurrently_changed_copy_is_kept_without_failing_the_commit() {
    let scene: Scene = Scene::new("post-landing-conflict", false);
    let (stand_in, _log): (PathBuf, PathBuf) = logging_git(scene.root.as_path(), 0);
    let mut context = git_context();
    context.real_git = stand_in;
    let mut transaction: LandedTransaction = landed(&scene, Vec::new());
    let record: AddedPath = shadow_record(&scene, "edited.txt", "before\n", "after\n");
    std::fs::write(scene.repo.join("edited.txt"), "the user's edit\n").expect("user edit");
    transaction.added_paths = vec![record];
    let outcome: PostLandingOutcome = complete_landed_transaction(PostLandingRequest {
        context: &context,
        landed: &transaction,
    })
    .expect("completed");
    assert_eq!(
        outcome.worktree.conflicted,
        vec![b"edited.txt".to_vec()]
    );
    assert!(outcome.worktree.rewritten.is_empty());
    assert_eq!(
        std::fs::read_to_string(scene.repo.join("edited.txt")).expect("kept edit"),
        "the user's edit\n"
    );
    assert!(!scene.directory.exists());
    scene.finish();
}

/// A shadow that cannot be removed fails the completion before maintenance and the actions start.
#[cfg(unix)]
#[test]
fn a_disposal_failure_stops_before_maintenance_and_the_actions() {
    use std::os::unix::fs::PermissionsExt;
    let scene: Scene = Scene::new("post-landing-dispose-fails", false);
    std::fs::create_dir_all(scene.shadow.join("objects")).expect("shadow");
    let (stand_in, log): (PathBuf, PathBuf) = logging_git(scene.root.as_path(), 0);
    let mut context = git_context();
    context.real_git = stand_in;
    let transaction: LandedTransaction = landed(&scene, Vec::new());
    let parent: PathBuf = scene.shadow.parent().expect("shadow parent").to_path_buf();
    std::fs::set_permissions(parent.as_path(), std::fs::Permissions::from_mode(0o555))
        .expect("read-only parent");
    let result = complete_landed_transaction(PostLandingRequest {
        context: &context,
        landed: &transaction,
    });
    std::fs::set_permissions(parent.as_path(), std::fs::Permissions::from_mode(0o755))
        .expect("restored parent");
    let failure: PostLandingError = result.expect_err("disposal failure");
    assert!(
        matches!(failure, PostLandingError::Dispose(_)),
        "{failure:?}"
    );
    assert!(scene.directory.exists());
    let lines: Vec<String> = sequence(log.as_path());
    assert!(
        position(lines.as_slice(), "maintenance run").is_none(),
        "{lines:?}"
    );
    scene.finish();
}

/// Maintenance whose config output cannot be decoded is logged and skipped; the commit stands.
#[test]
fn an_undecodable_maintenance_config_is_skipped() {
    let scene: Scene = Scene::new("post-landing-maintenance-undecodable", false);
    std::fs::create_dir_all(scene.shadow.join("objects")).expect("shadow");
    let log: PathBuf = scene.root.join("calls.log");
    let path: PathBuf = scene.root.join("git");
    executable(
        path.as_path(),
        format!(
            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\ncase \" $* \" in *\" config \"*) printf '\\377\\376'; exit 0;; esac\nexec {} \"$@\"\n",
            log.display(),
            crate::test_support::REAL_GIT,
        )
        .as_bytes(),
    );
    let mut context = git_context();
    context.real_git = path;
    let transaction: LandedTransaction = landed(&scene, Vec::new());
    let outcome: PostLandingOutcome = complete_landed_transaction(PostLandingRequest {
        context: &context,
        landed: &transaction,
    })
    .expect("completed despite maintenance");
    assert_eq!(outcome.maintenance, None);
    let lines: Vec<String> = sequence(log.as_path());
    assert!(
        position(lines.as_slice(), "maintenance run").is_none(),
        "{lines:?}"
    );
    scene.finish();
}

/// Each failure variant prints its own message without a step prefix, as the incumbent prints a thrown error.
#[test]
fn failures_display_their_message_alone() {
    assert_eq!(
        PostLandingError::Install(RecoveryError(String::from("copy"))).to_string(),
        "copy"
    );
    assert_eq!(
        PostLandingError::Dispose(RecoveryError(String::from("remove"))).to_string(),
        "remove"
    );
}