monochromatic-git-policy-cli 0.5.27

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
//! What:
//!  Running a forwarded Git command through the linked-worktree ignored-state copy:
//!  which invocations the copy applies to, and the child-process run that performs it.
//! Why:
//!  A command that creates a worktree must copy the ignored state once real Git returns, and a command run from a linked
//!  worktree must first recover a copy a dead process left; replacing the process with Git would drop both duties, so
//!  these invocations run Git as a child. No lock is held: concurrent commands never wait for one another.
//!  The main checkout is a source only for the configured rules file, so it takes this path only when the
//!  command creates a worktree and the configuration names that file.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
//! ```

use super::forwarding::{ChildOutcome, exit_code as child_exit_code, run_real_git};
use super::worktree_add_plan::AddPlan;
use super::worktree_remove_plan::RemovePlan;
use super::worktree_identity::WorktreeIdentity;
use std::ffi::OsString;
use std::path::{Path, PathBuf};

/// What:
///  Everything the copy needs to know about one forwarded invocation, decided before Git starts.
/// Why:
///   The plan decides that the copy applies; the run performs it with real Git and the process environment.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type CopyForward = { identity: GitWorktreeIdentity; command: ResolvedGitCommand; mainCheckoutPaths: string[] };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CopyFacts {
    /// The repository shape the invocation selects.
    pub identity: WorktreeIdentity,
    /// Whether the command, after alias resolution, creates a worktree.
    pub creates: bool,
    /// The destination the creating command's line names, in the form Git records it; absent for every other command and for
    ///  a form of `git worktree add` whose destination is not read.
    pub destination: Option<PathBuf>,
    /// The destination of a `git worktree add` in a form the cheap-worktree ladder serves; absent for every other
    ///  command and every form the ladder does not know.
    pub add_plan: Option<AddPlan>,
    /// The directory of a `git worktree remove` in the plain form the wrapper may serve with a subvolume destroy;
    ///  absent for every other command.
    pub remove_plan: Option<RemovePlan>,
}

/// What:
///  Whether the copy applies to an invocation.
/// Why:
///   Linked worktrees and bare repositories always take the lifecycle (recovery, creation);
///  the main checkout only when it creates a worktree and has paths to hand over, so its other commands
///  keep their plain forwarding and cost no filesystem reads.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// identity.kind !== 'outside-worktree' && (identity.kind !== 'main-worktree' || (creates && paths.length > 0))
/// ```
pub fn copy_applies(identity: &WorktreeIdentity, creates: bool) -> bool {
    return copy_applies_with(identity, creates, false);
}

/// What:
///  Whether the copy applies to an invocation, when a `git worktree add` or `git worktree remove` that the ladder
///  serves may also run in the main checkout.
/// Why:
///   The ladder snapshots or clones from the creating worktree, and the main checkout is one; a removal is served from
///  any checkout. A creating command from the main checkout takes the lifecycle whatever the configuration names.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// copyApplies(identity, creates) || (servesLadder && identity.kind === 'main-worktree')
/// ```
pub fn copy_applies_with(identity: &WorktreeIdentity, creates: bool, serves_ladder: bool) -> bool {
    // `match` on the repository shape; the main checkout takes the lifecycle when the command creates a worktree
    // or the ladder serves it.
    match identity {
        WorktreeIdentity::OutsideWorktree => return false,
        WorktreeIdentity::MainWorktree { .. } => {
            return serves_ladder || creates;
        }
        WorktreeIdentity::LinkedWorktree { .. } | WorktreeIdentity::BareRepository { .. } => {
            return true;
        }
    }
}

/// What:
///  How one forwarded run ended.
/// Why:
///   Only a run that failed before or after Git needs its own text; otherwise the status is Git's.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type ForwardedRun = { exitCode: number } | { stderr: string; exitCode: number };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ForwardedRun {
    /// Git ran (and the copy, when one applied, finished); this is the exit status.
    Ran(i32),
    /// The run could not start or settle; the standard error text and exit status.
    Failed {
        /// Text for standard error.
        stderr: String,
        /// Exit status.
        code: i32,
    },
}

/// What:
///  The directory the repository queries of the copy run in.
/// Why:
///   Every query names absolute paths, so any directory of the repository works; the worktree root exists whenever
///  the identity has a worktree, and a bare repository's common directory is itself a repository.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// identity.effectiveCwd
/// ```
#[cfg(unix)]
fn query_directory(identity: &WorktreeIdentity) -> PathBuf {
    // `match` on the repository shape: a worktree root, or the bare repository's directory.
    match identity {
        WorktreeIdentity::MainWorktree { worktree_root, .. }
        | WorktreeIdentity::LinkedWorktree { worktree_root, .. } => return worktree_root.clone(),
        WorktreeIdentity::BareRepository { common_dir, .. } => return common_dir.clone(),
        WorktreeIdentity::OutsideWorktree => return PathBuf::new(),
    }
}

/// What:
///  Runs real Git as a child with the given variables and reports how it ended.
/// Why:
///   One start for the plain and the settled runs, so both report an unstartable Git the same way.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await nanoSpawn(gitPath, args, { env, stdio: 'inherit' });
/// ```
fn run_plain(
    real_git: &Path,
    arguments: &[OsString],
    overlay: &[(OsString, OsString)],
) -> ForwardedRun {
    // `match` on the start result: the status is Git's, and an unstartable Git is an engine failure.
    match run_real_git(real_git, arguments, overlay) {
        Ok(outcome) => return ForwardedRun::Ran(child_exit_code(outcome)),
        Err(error) => {
            return ForwardedRun::Failed {
                stderr: format!("cli-git: cannot start {}: {error}\n", real_git.display()),
                code: super::action::ENGINE_FAILURE_EXIT_CODE,
            };
        }
    }
}

/// What:
///  Runs the forwarded Git command, settling the ignored-state copy around it when one applies.
/// Why:
///   Git's status is the answer unless the copy failed: a copy failure after a successful Git is status 2,
///  after a failed Git it keeps Git's status, and a failure of the lifecycle itself is status 2, each with its message.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
/// ```
pub fn run_forwarded_git(
    real_git: &Path,
    arguments: &[OsString],
    overlay: &[(OsString, OsString)],
    copy: Option<&CopyFacts>,
) -> ForwardedRun {
    #[cfg(unix)]
    {
        if let Some(facts) = copy {
            #[cfg(target_os = "linux")]
            if let Some(finished) = remove_by_subvolume(real_git, overlay, facts) {
                return finished;
            }
            return run_settled(real_git, arguments, overlay, facts);
        }
    }
    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
    #[cfg(not(unix))]
    {
        let _ = copy;
    }
    return run_plain(real_git, arguments, overlay);
}

/// What:
///  Remove the worktree a plain `git worktree remove` names by destroying its subvolume, when every condition holds.
/// Why:
///   Git's own removal unlinks every file; the destroy is one call. `None` leaves the whole command to Git; a removal
///  that destroyed the files but could not remove the registration fails the command with the repair steps and is never
///  handed to Git, whose removal would find the worktree already gone.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// facts.removePlan === undefined ? undefined : outcomeToRun(await removeSubvolumeWorktree(request))
/// ```
#[cfg(all(unix, target_os = "linux"))]
fn remove_by_subvolume(real_git: &Path, overlay: &[(OsString, OsString)], facts: &CopyFacts) -> Option<ForwardedRun> {
    use std::io::Write;
    use super::worktree_remove::{RemoveOutcome, RemoveRequest, remove_subvolume_worktree};
    use super::worktree_identity::WorktreeIdentity;
    let plan = facts.remove_plan.as_ref()?;
    let common_dir: &Path = match &facts.identity {
        WorktreeIdentity::MainWorktree { common_dir, .. }
        | WorktreeIdentity::LinkedWorktree { common_dir, .. }
        | WorktreeIdentity::BareRepository { common_dir, .. } => common_dir.as_path(),
        WorktreeIdentity::OutsideWorktree => return None,
    };
    let context: super::transaction_git::GitContext = super::transaction_git::GitContext {
        real_git: real_git.to_path_buf(),
        overlay: overlay.to_vec(),
        global_prefix: Vec::new(),
        common_dir: None,
    };
    match remove_subvolume_worktree(&RemoveRequest { context: &context, plan, common_dir }) {
        RemoveOutcome::LeftToGit => return None,
        RemoveOutcome::Removed => {
            // Exact terminal output: the person runs a destroy that cannot be undone and accepts the risk that comes with it.
            let _closed = writeln!(std::io::stderr(), "{}", super::worktree_remove::DESTROY_NOTICE);
            return Some(ForwardedRun::Ran(0));
        }
        RemoveOutcome::Incomplete(report) => {
            return Some(ForwardedRun::Failed { stderr: format!("{report}\n"), code: super::action::ENGINE_FAILURE_EXIT_CODE });
        }
    }
}

/// What:
///  The settled run: the lifecycle with real Git as its runner and the process's standard error as its stream.
/// Why:
///   Real Git runs as a child so the wrapper can copy the ignored state once it returns; no lock is held around it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
/// ```
#[cfg(unix)]
fn run_settled(
    real_git: &Path,
    arguments: &[OsString],
    overlay: &[(OsString, OsString)],
    facts: &CopyFacts,
) -> ForwardedRun {
    use std::io::Write;
    use super::checkout_workers::{insert_parallel_checkout, is_fallback_checkout, parallel_checkout_wanted};
    use super::transaction_git::GitContext;
    use super::worktree_copy::{GitStatus, GitStep, Invocation, exit_code, run_with_worktree_copy};
    use super::worktree_copy_error::WorktreeCopyError;

    let context: GitContext = GitContext {
        real_git: real_git.to_path_buf(),
        overlay: overlay.to_vec(),
        // Every copy query names absolute paths and leaves the caller's global options out.
        global_prefix: Vec::new(),
        // The copy queries send no input, so no scratch directory is needed.
        common_dir: None,
    };
    let invocation: Invocation = Invocation {
        identity: facts.identity.clone(),
        effective_cwd: query_directory(&facts.identity),
        creates: facts.creates,
        destination: facts.destination.clone(),
        opted_out: false,
        short_circuit: false,
        context,
        add_plan: facts.add_plan.clone(),
        #[cfg(target_os = "linux")]
        repair: super::package_repair::RepairSwitch::from_arguments(arguments),
    };
    let mut stderr = std::io::stderr();
    // Whether the checkout may get parallel workers, decided at the first checkout step from what the person set, and not again.
    let mut parallel: Option<bool> = None;
    // The runner starts Git as a child, so the wrapper is still there to copy once Git returns.
    let mut run_git = |step: &GitStep<'_>| -> Result<GitStatus, WorktreeCopyError> {
        let mut child_overlay: Vec<(OsString, OsString)> = overlay.to_vec();
        let mut child_arguments: Vec<OsString> = arguments.to_vec();
        match step {
            GitStep::Person => {
                // The person's `git worktree add` is the checkout of the new worktree: it gets parallel workers where nothing sets them.
                if facts.creates && *parallel.get_or_insert_with(|| { return parallel_checkout_wanted(&invocation.context, invocation.effective_cwd.as_path(), arguments); }) {
                    insert_parallel_checkout(&mut child_arguments);
                }
            }
            GitStep::WithoutCheckout(at) => {
                // The index was read from the same arguments, so the word before it is `add`.
                if *at == 0 || *at > child_arguments.len() || child_arguments[*at - 1] != "add" {
                    return Err(WorktreeCopyError(String::from("cli-git: the worktree plan does not match the arguments.")));
                }
                child_arguments.insert(*at, OsString::from("--no-checkout"));
            }
            GitStep::Other { arguments: other, environment } => {
                child_arguments = other.to_vec();
                child_overlay.extend(environment.iter().cloned());
                // The ladder's own checkout after a failed snapshot is the same checkout, so it takes the same option.
                if is_fallback_checkout(child_arguments.as_slice()) && *parallel.get_or_insert_with(|| { return parallel_checkout_wanted(&invocation.context, invocation.effective_cwd.as_path(), arguments); }) {
                    insert_parallel_checkout(&mut child_arguments);
                }
            }
        }
        match run_real_git(real_git, child_arguments.as_slice(), child_overlay.as_slice()) {
            Ok(ChildOutcome::Exited(code)) => return Ok(GitStatus { code: Some(code) }),
            Ok(ChildOutcome::Signaled(_)) => return Ok(GitStatus { code: None }),
            Err(error) => {
                return Err(WorktreeCopyError(format!(
                    "cli-git: cannot start {}: {error}",
                    real_git.display()
                )));
            }
        }
    };
    match run_with_worktree_copy(&invocation, &mut run_git, &mut stderr) {
        Ok(settlement) => {
            if let Some(failure) = &settlement.copy_failure {
                // A closed standard error leaves nowhere to report the failure, and the exit status still carries it.
                let _ = writeln!(stderr, "{}", failure.0);
            }
            return ForwardedRun::Ran(exit_code(&settlement));
        }
        Err(failure) => {
            return ForwardedRun::Failed {
                stderr: format!("{}\n", failure.0),
                code: super::action::ENGINE_FAILURE_EXIT_CODE,
            };
        }
    }
}

/// Forwarding-run controls stay out of the release executable.
#[cfg(test)]
#[path = "worktree_copy_forward_tests.rs"]
mod tests;