monochromatic-git-policy-cli 0.5.23

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
//! What:
//!  The retry of a commit the service sent back because a path it names changed on the branch since its base: the change
//!  the first preparation captured is merged onto the branch as it is now, and the worktree is not read again.
//! Why:
//!  A commit lands the bytes that were there when the person ran the command (`doc/decision/cli-git-concurrent-commits.md`,
//!  "Serial landing"; `SPEC.md`: "Cli-git never re-prepares a commit from current worktree bytes"). Preparing again from
//!  the worktree landed bytes written after the command, and bytes another command meant. The merge base is the
//!  preparation base, and a path the other commits changed that the captured change already contains is kept as captured
//!  (the same subsumption rule the earlier landing path applies, `replay_subsumption.rs`). The first merge is one Git
//!  run; the subsumption runs only for the paths that conflicted, so a retry that merges cleanly costs three runs here:
//!  `write-tree` for the captured tree, `merge-tree`, and `read-tree` of the merged tree into the private index.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const captured = await captureTree(readyCommit);              // after the service sent the commit back
//! const merged = await mergeOntoBranch(captured, currentTip);   // { tree } | { conflict }
//! ```

use super::private_index_request::indexed_request;
use super::commit_transaction::{TransactionResult, TransactionStop, failed};
use super::path_bytes::{os_from_path_bytes, path_message_text};
use super::policy_events::PolicyEvent;
use super::replay_capture_order::CaptureOrderDecision;
use super::replay_merge::{ReplayMerge, merge_replay_tree};
use super::replay_shared_paths::{SharedPath, list_shared_paths};
use super::replay_subsumption::{SubsumptionBase, subsume_landed_changes};
use super::service_messages::NOTHING_CHANGED;
use super::service_prepare::Ready;
use super::service_protocol::LandedCapture;
use super::transaction_git::{GitContext, GitRequest, run_git_checked};
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};

/// The transform that names the finding a retry ends with, as for every other concurrent-commit finding.
const CORE_ID: &str = "concurrent-commit";

/// What the first preparation captured, kept for the retry.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Captured {
    /// The commit the change was prepared against, which is the merge base of every retry.
    pub base: String,
    /// The tree of that base with the captured change applied.
    pub tree: String,
    /// When the worktree's bytes were captured (`CommitRequest::captured`); zero when unknown. A retry keeps the first
    ///  capture's stamp, because the merged bytes are not a new capture.
    pub captured: u64,
    /// The commits the service landed since, with the stamps of their captures, as its last answer told.
    pub landed: Vec<LandedCapture>,
}

/// What:
///  The stamp of a capture finished now: nanoseconds of `CLOCK_MONOTONIC`, zero where the platform has none.
/// Why:
///   That clock is the host's, one for every process, and never steps back, so a capture made later by another wrapper
///  process gets a larger stamp (`service_captures.rs`).
///
/// In TS you'd write (pseudocode):
/// ```ts
/// process.hrtime.bigint()
/// ```
pub fn capture_stamp() -> u64 {
    #[cfg(unix)]
    {
        let mut now = libc::timespec { tv_sec: 0, tv_nsec: 0 };
        // SAFETY: `clock_gettime` writes one `timespec` through a valid pointer.
        let read: i32 = unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &raw mut now) };
        if read != 0 {
            return 0;
        }
        let seconds: u64 = u64::try_from(now.tv_sec).unwrap_or(0);
        let nanoseconds: u64 = u64::try_from(now.tv_nsec).unwrap_or(0);
        return seconds.saturating_mul(1_000_000_000).saturating_add(nanoseconds).max(1);
    }
    #[cfg(not(unix))]
    {
        return 0;
    }
}

/// A retry: the change the first preparation captured, and which retry this is.
pub struct Retry<'a> {
    /// What the first preparation captured.
    pub captured: &'a Captured,
    /// Which retry this is, from 1, which names the retry's scratch files.
    pub attempt: u64,
}

/// The first commit on the branch since the preparation base that changed one of the conflicting paths.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LandedFirst {
    /// Its abbreviated name, as Git abbreviates it in this repository.
    pub short: String,
    /// Its subject.
    pub subject: String,
}

/// A retry that could not combine the captured change with the branch.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReplayConflict {
    /// The conflicting paths in Git's byte order, as the exact bytes Git reported.
    pub paths: Vec<Vec<u8>>,
    /// The commit that landed first on them, when it could be found.
    pub landed: Option<LandedFirst>,
    /// The Git directory of the worktree, where a saved message is kept.
    pub git_dir: PathBuf,
}

/// What merging the captured change onto the branch came to.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Merged {
    /// The change applies; the merged tree exists in the repository.
    Tree(String),
    /// The change overlaps what landed.
    Conflict(Vec<Vec<u8>>),
}

/// What the merge needs to know about where it runs.
pub struct MergeSite<'a> {
    /// How to run Git.
    pub context: &'a GitContext,
    /// The directory the commit was run from.
    pub cwd: &'a Path,
    /// The common Git directory, which holds the objects and which the object-only steps run against.
    pub common_dir: &'a Path,
    /// The object store receiving what the steps write.
    pub object_directory: &'a Path,
    /// The commit's private directory, where the subsumption keeps its scratch index.
    pub scratch: &'a Path,
}

/// What:
///  What a retry replays, from the preparation that was sent back: its tree on its base, its capture stamp, and the commits
///  the service says landed since.
/// Why:
///   Both the commit the service builds and the commit Git builds are replayed from the tree of their private index.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await retryOf(context, cwd, ready, landed)
/// ```
pub fn retry_of(context: &GitContext, cwd: &Path, ready: &Ready, landed: Vec<LandedCapture>) -> Result<Captured, TransactionStop> {
    let tree: String = captured_tree(context, cwd, ready.inputs.commit_index.as_path(), ready.inputs.capture.layout.object_directory.as_path())?;
    return Ok(Captured { base: ready.base.clone(), tree, captured: ready.captured_at, landed });
}

/// What:
///  The tree the private index holds, which is the captured change on its base.
/// Why:
///   The retry merges this tree, so it is written once, when the service sends the commit back.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await capturedTree(context, cwd, commitIndex, objectDirectory)
/// ```
pub fn captured_tree(context: &GitContext, cwd: &Path, commit_index: &Path, object_directory: &Path) -> Result<String, TransactionStop> {
    let written = run_git_checked(context, &indexed_request(cwd, &["write-tree"], Some(commit_index), Some(object_directory)))?;
    return Ok(String::from_utf8_lossy(written.stdout.as_slice()).trim().to_string());
}

/// The path as the Latin-1 text the shared-path listing names it by, so every byte survives the comparison.
fn latin1(bytes: &[u8]) -> String {
    return bytes.iter().map(|byte| { return char::from(*byte); }).collect();
}

/// What:
///  Merges the captured change onto `tip` with the preparation base as the merge base.
/// Why:
///   Clean merges, the common case, are one Git run. A conflict is looked at again for the paths whose captured bytes
///  already contain what landed (the person's worktree held the other commit's edit when this one was run), which the
///  merge alone reports as a conflict on adjacent edits; only paths still in conflict after that are a conflict.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await mergeCaptured(site, captured, tip) // { tree } | { conflict: paths }
/// ```
pub fn merge_captured(site: &MergeSite<'_>, captured: &Captured, tip: &str, attempt: u64) -> Result<Merged, TransactionStop> {
    let merge = |base: &str| -> Result<ReplayMerge, TransactionStop> {
        return merge_replay_tree(site.context, site.common_dir, site.cwd, base, tip, captured.tree.as_str())
            .map_err(|error| { return failed(format!("merging the commit onto the branch failed: {error:?}")); });
    };
    let conflicting: Vec<Vec<u8>> = match merge(captured.base.as_str())? {
        ReplayMerge::Merged { tree_oid } => return Ok(Merged::Tree(tree_oid)),
        ReplayMerge::Conflict { paths } => paths,
    };
    let names: Vec<String> = conflicting.iter().map(|path| { return latin1(path.as_slice()); }).collect();
    let shared: Vec<SharedPath> = list_shared_paths(site.context, site.common_dir, site.cwd, captured.base.as_str(), tip, captured.tree.as_str())
        .map_err(|error| { return failed(format!("listing the paths the branch changed failed: {error:?}")); })?
        .into_iter()
        .filter(|entry| { return names.contains(&entry.path); })
        .collect();
    let mut order: BTreeMap<String, CaptureOrderDecision> = BTreeMap::new();
    for entry in &shared {
        let decision: CaptureOrderDecision = capture_order(site, captured, tip, entry.path.as_str())?;
        order.insert(entry.path.clone(), decision);
    }
    let settled: SubsumptionBase = subsume_landed_changes(
        site.context,
        site.common_dir,
        site.object_directory,
        site.cwd,
        site.scratch,
        attempt,
        captured.base.as_str(),
        shared.as_slice(),
        &order,
    )
    .map_err(|error| { return failed(format!("comparing the commit with what landed failed: {error:?}")); })?;
    if settled.subsumed_paths.is_empty() && settled.kept_landed_paths.is_empty() {
        return Ok(Merged::Conflict(conflicting));
    }
    return match merge(settled.merge_base.as_str())? {
        ReplayMerge::Merged { tree_oid } => Ok(Merged::Tree(tree_oid)),
        ReplayMerge::Conflict { paths } => Ok(Merged::Conflict(paths)),
    };
}

/// What:
///  Which of this commit's capture and the landed commits' captures of one path is the later.
/// Why:
///   The service remembers the stamps of the commits it landed for this worktree. When every commit that changed the path
///  since the base is remembered with a stamp, and all are earlier than this capture, this capture holds their edits and
///  its bytes land; when all are later, theirs hold this commit's edit and the landed bytes stay. Anything else (a commit
///  the service does not know, a stamp that is missing, one earlier and one later) is not decided by order.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await captureOrder(site, captured, tip, path) // 'prepared' | 'landed' | 'unordered'
/// ```
fn capture_order(site: &MergeSite<'_>, captured: &Captured, tip: &str, path: &str) -> Result<CaptureOrderDecision, TransactionStop> {
    if captured.captured == 0 {
        return Ok(CaptureOrderDecision::Unordered);
    }
    let bytes: Vec<u8> = path.chars().filter_map(|character| { return u8::try_from(u32::from(character)).ok(); }).collect();
    let exclusion: String = format!("^{}", captured.base);
    let mut request: GitRequest = GitRequest::new(site.cwd, &["log", "--format=%H", "--no-color", tip, exclusion.as_str(), "--"]);
    let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
    pathspec.extend_from_slice(bytes.as_slice());
    request.arguments.push(os_from_path_bytes(pathspec.as_slice()));
    request.environment = vec![(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("0"))];
    let listed = run_git_checked(site.context, &request)?;
    let text: String = String::from_utf8_lossy(listed.stdout.as_slice()).into_owned();
    let stamps: Vec<Option<u64>> = text
        .lines()
        .filter(|line| { return !line.is_empty(); })
        .map(|commit| { return captured.landed.iter().find(|known| { return known.commit == commit; }).map(|known| { return known.captured; }); })
        .collect();
    if stamps.is_empty() || stamps.iter().any(Option::is_none) {
        return Ok(CaptureOrderDecision::Unordered);
    }
    let known: Vec<u64> = stamps.into_iter().flatten().collect();
    if known.iter().all(|stamp| { return *stamp < captured.captured; }) {
        return Ok(CaptureOrderDecision::Prepared);
    }
    if known.iter().all(|stamp| { return *stamp > captured.captured; }) {
        return Ok(CaptureOrderDecision::Landed);
    }
    return Ok(CaptureOrderDecision::Unordered);
}

/// What:
///  The first commit after `base`, up to `tip`, that changed one of `paths`, with its abbreviated name and subject.
/// Why:
///   The conflict names the commit that landed first, in the words the person sees in their own log.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await firstLanded(context, cwd, base, tip, paths) // { short, subject } | undefined
/// ```
pub fn first_landed(context: &GitContext, cwd: &Path, base: &str, tip: &str, paths: &[Vec<u8>]) -> Result<Option<LandedFirst>, TransactionStop> {
    let exclusion: String = format!("^{base}");
    let mut request: GitRequest = GitRequest::new(
        cwd,
        &["log", "--reverse", "--format=%h%x00%s", "--no-color", "--no-show-signature", tip, exclusion.as_str(), "--"],
    );
    for path in paths {
        let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
        pathspec.extend_from_slice(path.as_slice());
        request.arguments.push(os_from_path_bytes(pathspec.as_slice()));
    }
    request.environment = vec![(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("0"))];
    let listed = run_git_checked(context, &request)?;
    let text: String = String::from_utf8_lossy(listed.stdout.as_slice()).into_owned();
    let Some(first) = text.lines().find(|line| { return !line.is_empty(); }) else { return Ok(None) };
    let (short, subject) = first.split_once('\0').unwrap_or((first, ""));
    return Ok(Some(LandedFirst { short: short.to_string(), subject: subject.to_string() }));
}

/// The paths as the person reads them: one in quotes, several as a list.
fn paths_text(paths: &[Vec<u8>]) -> String {
    let quoted: Vec<String> = paths.iter().map(|path| { return format!("`{}`", path_message_text(path.as_slice())); }).collect();
    return match quoted.as_slice() {
        [only] => format!("the file {only}"),
        many => format!("these files: {}", many.join(", ")),
    };
}

/// What:
///  The words for a conflict: what overlapped, which commit landed first, where the message is saved, how to commit again.
/// Why:
///   Plain words, every input named and every way forward given, nothing about how the commit is landed.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// conflictText(conflict, savedMessage)
/// ```
pub fn conflict_text(conflict: &ReplayConflict, saved: Option<&Path>) -> String {
    let landed: String = match &conflict.landed {
        Some(first) => format!("the commit {} (\"{}\")", first.short, first.subject),
        None => String::from("another commit"),
    };
    let look: String = match &conflict.landed {
        Some(first) => format!("Look at what that commit changed with `git show {}`, bring your", first.short),
        None => String::from("Look at what the branch changed, bring your"),
    };
    let again: String = match saved {
        Some(file) => format!(
            "Your message is saved in {}. {look} edit up to date with it, and run the commit again with `git commit -F {}` and the same paths.",
            file.display(),
            file.display()
        ),
        None => format!("{look} edit up to date with it, and run the commit again."),
    };
    return format!(
        "This commit was not made. While it was being prepared, {landed} changed {}, which this commit also changes, and the two \
         changes cannot be combined. {again} {NOTHING_CHANGED}",
        paths_text(conflict.paths.as_slice())
    );
}

/// The finding a conflict ends the command with.
pub fn conflict_result(conflict: &ReplayConflict, saved: Option<&Path>) -> TransactionResult {
    return TransactionResult {
        events: vec![PolicyEvent::CoreFinding { core_id: CORE_ID, code: "replay-conflict", message: conflict_text(conflict, saved) }],
        exit_code: 1,
        landed_oid: None,
        uses_lfs: false,
    };
}

/// The ending of a retry whose merged result is what the branch already holds.
pub fn already_committed_result(branch: &str) -> TransactionResult {
    return TransactionResult {
        events: vec![PolicyEvent::CoreFinding {
            core_id: CORE_ID,
            code: "already-committed",
            message: format!(
                "This commit was not made. While it was being prepared, other commits put everything it changes on the branch {branch}, \
                 so nothing is left to commit. Run `git log` to see them. {NOTHING_CHANGED}"
            ),
        }],
        exit_code: 1,
        landed_oid: None,
        uses_lfs: false,
    };
}

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