monochromatic-git-policy-cli 0.5.33

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
//! What:
//!  What the ladder does after real Git returned: bind the add to the directories Git made, the snapshot rung's steps (read
//!  the new `HEAD`, snapshot, apply the tree difference, swap into place, print Git's checkout line), the clone rung's steps,
//!  and the fall back to Git's own `reset --hard` in the bound worktree when a step cannot finish.
//! Why:
//!  Every step before the swap leaves the destination exactly as Git's `--no-checkout` run made it, so a failure there
//!  completes the worktree the way Git would have. The ladder never deletes or unregisters a worktree: when the fallback
//!  fails, or the bound objects are no longer what they were, the worktree and its registration stay exactly as they are and
//!  one loud report names what is incomplete and the command that removes it. The only deletions are of objects this process
//!  made and still checks: the stage it took and never exchanged. The line that Git's
//!  own checkout would have printed is printed here, so the person cannot tell the rungs apart except by the time they take.
//!  No hook runs on any rung: every real-Git start of the wrapper switches hooks off (`hooks_off.rs`).
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const result = await ladder.finish({ git, runGit, context, out });
//! ```

use crate::add_timing::step as timed;
use crate::diagnostic_log::{debug, warn};
use crate::fs_beneath::ObjectId;
use crate::transaction_git::GitContext;
use crate::worktree_add_binding::{Bound, bind_registration, verify_association};
use crate::worktree_add_plan::AddPlan;
use crate::worktree_clone::{Cloned, clone_tracked_files, leftover_report};
use crate::worktree_copy::{GitRunner, GitStatus};
use crate::worktree_copy_error::WorktreeCopyError;
use crate::worktree_copy_stage_claim::StageClaim;
use crate::worktree_ladder_run::{Ladder, LadderResult, Rung, discard_stage};
use crate::worktree_dirty_restore::{left_out_note, record_left_out, restore_dirty};
use crate::worktree_ladder_target::{Target, apply_difference, checkout_environment, prepare_target, run_step};
use crate::worktree_slow_route::{SlowRoute, explain_failure};
use crate::worktree_snapshot::{PlaceError, put_in_place, settle_displaced, take_snapshot};
use crate::worktree_snapshot_source::{ChangedPaths, SnapshotSource};
use crate::worktree_subvolume::settle_subvolume_destination;
use crate::worktree_trash::{TrashManifest, dispose_pending_trash};
use std::ffi::OsString;
use std::io::Write;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};

/// Tag used for diagnostics from this module.
const TAG: &str = "worktreeLadder";

/// Why a rung ended before it finished, and whether the object at the stage name is still the snapshot this process made.
struct Ended {
    /// The reason, in words.
    reason: String,
    /// Whether the stage name holds this process's snapshot, so that destroying it destroys nothing else.
    stage_is_ours: bool,
}

impl From<String> for Ended {
    fn from(reason: String) -> Ended {
        return Ended { reason, stage_is_ours: true };
    }
}

impl From<PlaceError> for Ended {
    fn from(error: PlaceError) -> Ended {
        return Ended { reason: error.reason, stage_is_ours: error.stage_is_ours };
    }
}

/// What:
///  Everything before the swap and the swap itself; the error is the reason the rung could not finish.
/// Why:
///   The caller destroys the stage on an error that left the stage name holding the snapshot, and completes the worktree
///  with Git's own checkout. `made` receives the identity of the stage as soon as it exists.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function serveSnapshot(args): Promise<string>; // the checkout line; throws a reason
/// ```
fn serve_snapshot(source: &SnapshotSource, plan: &AddPlan, stage: &Path, bound: &Bound, made: &mut Option<ObjectId>, run_git: &mut GitRunner, context: &GitContext) -> Result<String, Ended> {
    verify_association(bound)?;
    let target: Target = timed("prepareTarget", || { return prepare_target(source, plan, context, bound); })?;
    let trash: Option<TrashManifest> = match timed("takeSnapshot", || { return take_snapshot(source, plan, stage, &bound.admin, target.pointer.as_slice()); }) {
        Ok(taken) => {
            *made = Some(taken.stage);
            taken.trash
        }
        Err(failure) => {
            *made = failure.stage;
            return Err(Ended::from(failure.reason));
        }
    };
    verify_association(bound)?;
    if source.dirty.is_some() {
        timed("restoreDirty", || { return restore_dirty(source, &target, stage, run_git, context); })?;
    } else {
        timed("applyDifference", || { return apply_difference(source, &target, stage, run_git); })?;
    }
    let mode: u32 = std::fs::metadata(source.root.as_path()).map_err(|error| { return format!("the source's root is unreadable ({error})"); })?.mode() & 0o7777;
    timed("exchange", || { return put_in_place(stage, plan.destination.as_path(), mode, bound.binding.destination_id); })?;
    for note in timed("settleDisplaced", || { return settle_displaced(stage, plan.destination.as_path(), bound.binding.destination_id, target.pointer.as_slice()); }) {
        warn(TAG, format!("cli-git: {note}").as_str());
    }
    // What a snapshot of the main checkout set aside is emptied by the service; nothing is set aside for a linked worktree. The
    //  worktree is in place and complete, so a trash that cannot be emptied is reported and never fails the add.
    //  `--quiet` silences the progress lines of the hand-over; a failure is still reported below.
    let mut notices: Box<dyn std::io::Write> = if plan.quiet { Box::new(std::io::sink()) } else { Box::new(std::io::stderr()) };
    if let Err(reason) = timed("disposeTrash", || { return dispose_pending_trash(context, plan.destination.as_path(), target.admin.as_path(), trash.as_ref(), &mut notices); }) {
        warn(TAG, format!("cli-git: {reason}").as_str());
    }
    return Ok(target.line);
}

/// What:
///  The clone rung: clone the tracked files into the destination, install the index, apply the tree difference.
/// Why:
///   The destination is filled in place; an error removes nothing: the entries the run created stay, are reported, and Git's
///  own checkout completes the worktree and overwrites the tracked ones.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function serveClones(args): Promise<string>; // the checkout line; throws a reason
/// ```
fn serve_clones(source: &SnapshotSource, plan: &AddPlan, bound: &Bound, run_git: &mut GitRunner, context: &GitContext) -> Result<String, String> {
    verify_association(bound)?;
    let target: Target = prepare_target(source, plan, context, bound)?;
    let cloned: Cloned = clone_tracked_files(source, &bound.destination, &bound.admin)?;
    let applied: Result<(), String> = verify_association(bound).and_then(|()| { return apply_difference(source, &target, plan.destination.as_path(), run_git); });
    if let Err(reason) = applied {
        warn(TAG, format!("cli-git: {}", leftover_report(cloned.created(), plan.destination.as_path())).as_str());
        return Err(reason);
    }
    return Ok(target.line);
}

/// What:
///  Complete the worktree the way Git does after a refused rung: `git reset --hard --no-recurse-submodules`, in the bound
///  worktree and nowhere else.
/// Why:
///   Real Git ran with `--no-checkout`, so the destination holds only its `.git` file; this is Git's own checkout step.
///  The binding is verified first and again after, so a worktree that was moved or replaced meanwhile is never reset and a
///  success against a replacement is never taken for the add's. Git's `post-checkout` hook is not run: every real-Git start
///  of the wrapper switches hooks off (`hooks_off.rs`).
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function checkoutLikeGit(args): Promise<GitStatus>; // failure when the binding does not hold
/// ```
fn checkout_like_git(plan: &AddPlan, bound: &Bound, run_git: &mut GitRunner) -> GitStatus {
    let destination: &Path = plan.destination.as_path();
    if let Err(reason) = verify_association(bound) {
        warn(TAG, format!("cli-git: the checkout of {} was not started: {reason}", destination.display()).as_str());
        return GitStatus { code: Some(1) };
    }
    let mut arguments: Vec<OsString> = ["-C"].iter().map(OsString::from).chain([destination.as_os_str().to_os_string()]).chain(["reset", "--hard", "--no-recurse-submodules"].iter().map(OsString::from)).collect();
    if plan.quiet {
        arguments.push(OsString::from("--quiet"));
    }
    let environment: Vec<(OsString, OsString)> = checkout_environment(bound.binding.admin.as_path(), destination);
    let status: GitStatus = match run_step(run_git, arguments.as_slice(), environment.as_slice()) {
        Ok(status) => status,
        Err(reason) => {
            warn(TAG, format!("Git's checkout of {} could not start ({reason})", destination.display()).as_str());
            return GitStatus { code: Some(1) };
        }
    };
    if let (true, Err(reason)) = (status.succeeded(), verify_association(bound)) {
        warn(TAG, format!("cli-git: the checkout of {} ran, but it cannot be taken for the add's: {reason}", destination.display()).as_str());
        return GitStatus { code: Some(1) };
    }
    return status;
}

/// What:
///  Say what an add left, loudly and once, with the commands that finish or remove it.
/// Why:
///   The ladder deletes and unregisters nothing, so a worktree that could not be completed stays registered and incomplete,
///  as Git's own interrupted add leaves it; the person is told where and how to remove it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// reportIncomplete(plan);
/// ```
fn report_incomplete(plan: &AddPlan) {
    let shown: String = plan.destination.display().to_string();
    warn(TAG, format!("cli-git: the `git worktree add` of {shown} could not be completed; the worktree and its registration were left as they are. `git -C {shown} reset --hard` retries its checkout, and `git worktree remove --force {shown}` removes it.").as_str());
}

/// What:
///  Record that the add is complete, saying so in the log when the record cannot be written.
/// Why:
///   Without the marker a later command reports the add as interrupted although the worktree is whole; the report changes
///  nothing, so a failure here is a warning and not a failure of the command.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await markComplete(claim);
/// ```
fn mark_complete(claim: &StageClaim, swapped: bool) {
    let marked: Result<(), WorktreeCopyError> = timed("markComplete", || { return if swapped { claim.mark_complete_unsynced() } else { claim.mark_complete() }; });
    if let Err(error) = marked {
        warn(TAG, format!("the completion of the add could not be recorded ({}); a later command may report it as interrupted although it is whole", error.0).as_str());
    }
}

/// What:
///  Complete the worktree with Git's own checkout after a refused rung. When that fails, report what was left.
/// Why:
///   Nothing is removed on a failure: the worktree and its registration stay and the person gets the report.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const status = await completeLikeGit({ plan, claim, bound, runGit });
/// ```
fn complete_like_git(plan: &AddPlan, claim: &StageClaim, bound: &Bound, run_git: &mut GitRunner) -> GitStatus {
    let status: GitStatus = checkout_like_git(plan, bound, run_git);
    if status.succeeded() {
        mark_complete(claim, false);
    } else {
        report_incomplete(plan);
    }
    return status;
}

/// What:
///  Bind the add to the directories Git made, and write that binding to the add's record.
/// Why:
///   Every write below goes to the bound worktree and is checked against the binding. A binding that cannot be proven ends
///  the ladder for this add, leaving what Git made as it is.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const bound = await bind(claim, source, plan); // throws a reason
/// ```
fn bind(claim: &StageClaim, source: &SnapshotSource, plan: &AddPlan) -> Result<Bound, String> {
    let bound: Bound = timed("bindRegistration", || { return bind_registration(source.common_dir.as_path(), plan.destination.as_path()); })?;
    if let Err(error) = timed("recordBinding", || { return claim.record_binding(&bound.binding); }) {
        warn(TAG, format!("the binding of the add could not be recorded ({}); an interruption will be reported by path only", error.0).as_str());
    }
    return Ok(bound);
}

/// What:
///  Serve the clone rung after real Git returned, or complete the worktree with Git's own checkout.
/// Why:
///   A failed attempt has removed what it created; the ignored state is copied by the lifecycle afterwards, as for Git's
///  checkout, so nothing is reported as carried.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function finishClones(args): Promise<LadderResult>;
/// ```
fn finish_clones(source: &SnapshotSource, plan: &AddPlan, git: GitStatus, claim: &StageClaim, run_git: &mut GitRunner, context: &GitContext, out: &mut dyn Write, slow: &mut Option<SlowRoute>) -> LadderResult {
    if !git.succeeded() {
        return LadderResult { git, carried: None };
    }
    let bound: Bound = match bind(claim, source, plan) {
        Ok(found) => found,
        Err(reason) => {
            warn(TAG, format!("cli-git: {} was left as Git made it, without a checkout: {reason}", plan.destination.display()).as_str());
            report_incomplete(plan);
            return LadderResult { git: GitStatus { code: Some(1) }, carried: None };
        }
    };
    return match serve_clones(source, plan, &bound, run_git, context) {
        Ok(line) => {
            debug(TAG, format!("clones served {}", plan.destination.display()).as_str());
            mark_complete(claim, false);
            // A closed standard output leaves nowhere to print Git's line; the exit status still carries the result.
            let _written = out.write_all(line.as_bytes());
            LadderResult { git, carried: None }
        }
        Err(reason) => {
            debug(TAG, format!("the clone rung ended: {reason}; Git checks the worktree out").as_str());
            // The clones failed after the cause that already sent the add this way, if any: the person hears the later one.
            *slow = Some(explain_failure(reason.as_str()));
            LadderResult { git: complete_like_git(plan, claim, &bound, run_git), carried: None }
        }
    };
}

/// What:
///  Serve the snapshot rung after real Git returned, or complete the worktree with Git's own checkout.
/// Why:
///   An error that leaves the stage name holding the snapshot destroys the stage (checking its identity) and falls to Git's
///  checkout; an error that leaves somebody else's directory there destroys nothing and ends the add as failed, with the
///  names in the message.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function finishSnapshot(args): Promise<LadderResult>;
/// ```
fn finish_snapshot(source: &SnapshotSource, plan: &AddPlan, stage: &Path, claim: &StageClaim, run_git: &mut GitRunner, context: &GitContext, out: &mut dyn Write, slow: &mut Option<SlowRoute>) -> LadderResult {
    let bound: Bound = match bind(claim, source, plan) {
        Ok(found) => found,
        Err(reason) => {
            warn(TAG, format!("cli-git: {} was left as Git made it, without a checkout: {reason}", plan.destination.display()).as_str());
            report_incomplete(plan);
            return LadderResult { git: GitStatus { code: Some(1) }, carried: None };
        }
    };
    let mut made: Option<ObjectId> = None;
    return match serve_snapshot(source, plan, stage, &bound, &mut made, run_git, context) {
        Ok(line) => {
            debug(TAG, format!("the snapshot served {}", plan.destination.display()).as_str());
            mark_complete(claim, true);
            // A closed standard output leaves nowhere to print Git's line; the exit status still carries the result.
            let _written = out.write_all(line.as_bytes());
            // A snapshot of the main checkout prints no copy summary, so it says what it did, on standard error; `--quiet` stays silent.
            if source.git_dir == source.common_dir && !plan.quiet {
                let _noted = std::io::stderr().write_all(crate::worktree_trash::MAIN_SNAPSHOT_NOTE.as_bytes());
                if let Some(changed) = source.dirty.as_ref().filter(|found: &&ChangedPaths| { return found.has_changes(); }) {
                    let _left_out = std::io::stderr().write_all(left_out_note(changed).as_bytes());
                }
            }
            if let Some(changed) = source.dirty.as_ref().filter(|found: &&ChangedPaths| { return found.has_changes(); }) {
                record_left_out(plan.destination.as_path(), changed);
            }
            LadderResult { git: GitStatus { code: Some(0) }, carried: Some(plan.destination.clone()) }
        }
        Err(ended) if ended.stage_is_ours => {
            debug(TAG, format!("the snapshot rung ended: {}; Git checks the worktree out", ended.reason).as_str());
            discard_stage(stage, made);
            *slow = Some(explain_failure(ended.reason.as_str()));
            LadderResult { git: complete_like_git(plan, claim, &bound, run_git), carried: None }
        }
        Err(ended) => {
            warn(TAG, format!("cli-git: the add of {} ended in a state the wrapper cannot untangle: {}; {} is not touched", plan.destination.display(), ended.reason, stage.display()).as_str());
            report_incomplete(plan);
            LadderResult { git: GitStatus { code: Some(1) }, carried: None }
        }
    };
}

/// Release the claim on a stage name, saying so when that fails; flushing nothing when a subvolume was swapped into place.
fn release_claim(claim: StageClaim, swapped: bool) {
    let released: Result<(), WorktreeCopyError> = timed("releaseClaim", || { return if swapped { claim.release_unsynced() } else { claim.release() }; });
    if let Err(error) = released {
        warn(TAG, format!("the stage claim could not be released ({})", error.0).as_str());
    }
}

impl Ladder {
    /// What:
    ///  Settle the ladder after real Git returned `git`: serve the snapshot rung or fall back to Git's checkout, or settle
    ///  the empty subvolume of the Git rung.
    /// Why:
    ///   The result carries the status the person should see and the destination whose ignored state a snapshot carried. The
    ///  claim is always released: what an add left is reported when it happens, once.
    ///
    /// In TS you'd write (pseudocode):
    /// ```ts
    /// const result = await ladder.finish({ git, runGit, context, out });
    /// ```
    pub fn finish(self, git: GitStatus, run_git: &mut GitRunner, context: &GitContext, out: &mut dyn Write) -> LadderResult {
        let mut slow: Option<SlowRoute> = self.slow;
        let result: LadderResult = match (self.rung, self.plan.as_ref()) {
            (Rung::Snapshot(source, claim), Some(plan)) => {
                if !git.succeeded() {
                    release_claim(claim, false);
                    return LadderResult { git, carried: None };
                }
                let stage: PathBuf = claim.container().to_path_buf();
                let result: LadderResult = finish_snapshot(&source, plan, stage.as_path(), &claim, run_git, context, out, &mut slow);
                // A snapshot that served the add carried its ignored state: only then was a subvolume swapped into place.
                release_claim(claim, result.carried.is_some());
                result
            }
            (Rung::Snapshot(_, claim), None) => {
                release_claim(claim, false);
                LadderResult { git, carried: None }
            }
            (Rung::Clone(source, subvolume, claim), Some(plan)) => {
                let result: LadderResult = finish_clones(&source, plan, git, &claim, run_git, context, out, &mut slow);
                if let Some(created) = subvolume.as_ref() {
                    settle_subvolume_destination(created);
                }
                release_claim(claim, false);
                result
            }
            (Rung::Clone(_, subvolume, claim), None) => {
                if let Some(created) = subvolume.as_ref() {
                    settle_subvolume_destination(created);
                }
                release_claim(claim, false);
                LadderResult { git, carried: None }
            }
            (Rung::Git(subvolume), _) => {
                if let Some(created) = subvolume.as_ref() {
                    settle_subvolume_destination(created);
                }
                LadderResult { git, carried: None }
            }
        };
        // Only an add that succeeded is told as a slow one; a refused or failed add has a worse thing to say.
        if let (Some(route), Some(plan), true, None) = (slow.as_ref(), self.plan.as_ref(), result.git.succeeded(), result.carried.as_ref()) {
            route.tell(plan);
        }
        return result;
    }
}

/// Ladder finish controls stay out of the release executable.
#[cfg(test)]
#[path = "worktree_ladder_finish_tests.rs"]
mod tests;