monochromatic-git-policy-cli 0.1.2

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
Documentation
//! What:
//!  The real index a landing installs, computed against the real index as it is at landing,
//!  never against the invocation-time copy.
//! Why:
//!  A landing that copied the invocation-time index would erase another process's staging
//!  and stage a revert of content that already landed. Index mode takes the exact private index only
//!  when nothing was restaged; otherwise it merges the landed entries path by path, and explicit-path
//!  mode resets only the committed paths and the guarded paths still holding their invocation entry
//!  (`src/policy-engine/commit-landing-index.ts`).
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! await computeLandingPostIndex({ gitPath, cwd, mode, preLandingIndexPath, capturedIndexPath, landedIndexPath, postIndexPath, ... });
//! ```

/// Index copy with the timestamps Git reads.
use super::commit_index::copy_index_with_timestamps;
/// Snapshot comparison: exact bytes, refusing symbolic links.
use super::recovery_files::files_equal;
/// Warning text to the diagnostic log.
use super::diagnostic_log::warn;
/// Commit selection mode of the transaction.
use super::transaction_journal::TransactionMode;
/// Running real Git, checked and unchecked.
use super::transaction_git::{GitContext, GitFailure, GitOutput, GitRequest, run_git, run_git_checked};
/// Native `HashMap` of stage records and the ordered path list.
use std::collections::HashMap;
/// `OsString` is owned operating-system text; `Path`/`PathBuf` are filesystem paths.
use std::ffi::OsString;
use std::path::Path;

/// Module tag for diagnostics.
const TAG: &str = "computeLandingPostIndex";

/// What:
///  The inputs of one post-index computation.
/// Why:
///   The index artifacts and the landed tree travel together; a struct keeps the call readable.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// { mode, preLandingIndexPath, capturedIndexPath, landedIndexPath, postIndexPath, landedTreeOid, baseRevision, committedPaths, guardedPaths, exactPrivateIndex }
/// ```
pub struct PostIndexInput<'input> {
    /// Commit selection mode.
    pub mode: TransactionMode,
    /// Exact copy of the real index taken at landing.
    pub pre_landing_index: &'input Path,
    /// Invocation-time index copy.
    pub captured_index: &'input Path,
    /// Private index the landed commit was made from.
    pub landed_index: &'input Path,
    /// Post-index written by this computation.
    pub post_index: &'input Path,
    /// Tree the landing installs.
    pub landed_tree: &'input str,
    /// Preparation base commit, or the empty tree when unborn.
    pub base_revision: &'input str,
    /// Paths an explicit-path commit carries, including policy-added paths.
    pub committed_paths: &'input [String],
    /// Paths a commit hook changed.
    pub guarded_paths: &'input [String],
    /// Whether `landed_index` is exactly the index the commit was prepared from.
    pub exact_private_index: bool,
}

/// What:
///  Runs real Git with the literal-pathspec override that keeps `:(top,literal)` paths concrete.
/// Why:
///   The caller may disable pathspec magic, and the landing names exact paths.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// environment: { GIT_LITERAL_PATHSPECS: '0' }
/// ```
fn literal_pathspecs() -> Vec<(OsString, OsString)> {
    return vec![(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("0"))];
}

/// What:
///  Decodes Git output strictly as UTF-8.
/// Why:
///   A path or subject that is not UTF-8 must fail the landing, not be replaced.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// new TextDecoder('utf-8', { fatal: true }).decode(bytes)
/// ```
fn decode(bytes: &[u8]) -> Result<String, GitFailure> {
    return String::from_utf8(bytes.to_vec())
        .map_err(|error| { return GitFailure(format!("Git output is not UTF-8: {error}")); });
}

/// What:
///  Splits NUL-terminated records and drops empty ones.
/// Why:
///   Every `-z` listing ends each record with NUL.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// decode(bytes).split('\0').filter(nonempty)
/// ```
fn records(bytes: &[u8]) -> Result<Vec<String>, GitFailure> {
    return Ok(decode(bytes)?
        .split('\0')
        .filter(|record| { return !record.is_empty(); })
        .map(str::to_string)
        .collect());
}

/// What:
///  Lists the stage records of concrete paths in one index, grouped by path.
/// Why:
///   Comparing the stages of a path decides whether anyone restaged it since invocation.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// listStages({ gitPath, cwd, indexPath, paths });
/// ```
fn list_stages(
    context: &GitContext,
    cwd: &Path,
    index: &Path,
    paths: &[String],
) -> Result<HashMap<String, Vec<String>>, GitFailure> {
    let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
    if paths.is_empty() {
        return Ok(grouped);
    }
    let mut arguments: Vec<String> = vec!["ls-files".into(), "--stage".into(), "-z".into(), "--full-name".into(), "--".into()];
    arguments.extend(paths.iter().map(|path| format!(":(top,literal){path}")));
    let mut request = GitRequest::new(cwd, &arguments);
    request.index = Some(index.to_path_buf());
    request.environment = literal_pathspecs();
    let output: GitOutput = run_git_checked(context, &request)?;
    for record in records(&output.stdout)? {
        let Some((_, path)) = record.split_once('\t') else {
            return Err(GitFailure(format!("ls-files record has no path: {record}")));
        };
        grouped.entry(path.to_string()).or_default().push(record.clone());
    }
    return Ok(grouped);
}

/// What:
///  Lists the paths two trees differ in, without rename pairing.
/// Why:
///   The landed tree's changes are the paths the landing introduces.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// git diff-tree -r --no-renames --name-only -z from to
/// ```
fn changed_paths(context: &GitContext, cwd: &Path, from: &str, to: &str) -> Result<Vec<String>, GitFailure> {
    let request = GitRequest::new(cwd, &["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to]);
    let output: GitOutput = run_git_checked(context, &request)?;
    return records(&output.stdout);
}

/// What:
///  Lists paths the landed tree changes relative to what was staged at invocation.
/// Why:
///   When the invocation-time index cannot form a tree, nothing is added by this rule.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// changedSinceCaptured({ gitPath, cwd, capturedIndexPath, landedTreeOid });
/// ```
fn changed_since_captured(
    context: &GitContext,
    cwd: &Path,
    captured: &Path,
    landed_tree: &str,
) -> Result<Vec<String>, GitFailure> {
    let mut request = GitRequest::new(cwd, &["write-tree"]);
    request.index = Some(captured.to_path_buf());
    let output: GitOutput = run_git(context, &request)
        .map_err(|error| { return GitFailure(format!("git write-tree could not start: {error}")); })?;
    if !output.succeeded() {
        return Ok(Vec::new());
    }
    let tree: String = decode(&output.stdout)?.trim().to_string();
    return changed_paths(context, cwd, tree.as_str(), landed_tree);
}

/// What:
///  Merges the landed entries of every changed path that nobody restaged since invocation.
/// Why:
///   Other staging stays as it is; only paths still holding their invocation entry take the landed one,
///  and a path the commit deleted is removed from the index.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await runTransactionGit({ indexPath: postIndexPath, args: ['update-index', '-z', '--index-info'], input });
/// ```
fn merge_landed_entries(
    context: &GitContext,
    cwd: &Path,
    artifacts: &Artifacts,
    changed: &[String],
) -> Result<(), GitFailure> {
    let captured = list_stages(context, cwd, artifacts.captured, changed)?;
    let current = list_stages(context, cwd, artifacts.post, changed)?;
    let landed = list_stages(context, cwd, artifacts.landed, changed)?;
    let untouched: Vec<&String> = changed
        .iter()
        .filter(|path| {
            return captured.get(path.as_str()).map(Vec::as_slice).unwrap_or(&[])
                == current.get(path.as_str()).map(Vec::as_slice).unwrap_or(&[]);
        })
        .collect();
    if untouched.is_empty() {
        return Ok(());
    }
    let zero: String = "0".repeat(artifacts.landed_tree.len());
    let mut body: String = String::new();
    for path in untouched {
        match landed.get(path.as_str()) {
            Some(entries) => {
                for entry in entries {
                    body.push_str(entry);
                    body.push('\0');
                }
            }
            None => {
                body.push_str(&format!("0 {zero}\t{path}\0"));
            }
        }
    }
    let mut request = GitRequest::new(cwd, &["update-index", "-z", "--index-info"]);
    request.index = Some(artifacts.post.to_path_buf());
    request.input = Some(body.into_bytes());
    run_git_checked(context, &request)?;
    return Ok(());
}

/// What:
///  The four index files and the landed tree of one computation.
/// Why:
///   The helpers take the same paths together; grouping keeps their signatures short.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// { capturedIndexPath, postIndexPath, landedIndexPath, landedTreeOid }
/// ```
struct Artifacts<'artifacts> {
    /// Invocation-time index copy.
    captured: &'artifacts Path,
    /// Post-index being written.
    post: &'artifacts Path,
    /// Private index the commit was made from.
    landed: &'artifacts Path,
    /// Tree the landing installs.
    landed_tree: &'artifacts str,
}

/// What:
///  Resets guarded paths to the landed tree while they still hold their invocation entry.
/// Why:
///   A path restaged after invocation keeps the staging the other process made, and the warning names it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await resetGuardedPaths({ gitPath, cwd, capturedIndexPath, postIndexPath, landedTreeOid, guardedPaths });
/// ```
fn reset_guarded_paths(
    context: &GitContext,
    cwd: &Path,
    artifacts: &Artifacts,
    guarded: &[String],
) -> Result<(), GitFailure> {
    if guarded.is_empty() {
        return Ok(());
    }
    let captured = list_stages(context, cwd, artifacts.captured, guarded)?;
    let current = list_stages(context, cwd, artifacts.post, guarded)?;
    let mut untouched: Vec<&String> = Vec::new();
    for path in guarded {
        let before: &[String] = captured.get(path.as_str()).map(Vec::as_slice).unwrap_or(&[]);
        let now: &[String] = current.get(path.as_str()).map(Vec::as_slice).unwrap_or(&[]);
        if before == now {
            untouched.push(path);
        } else {
            warn(
                TAG,
                &format!(
                    "A commit hook changed {path}, but it was staged again while the commit ran; the committed bytes are in HEAD and your staged entry was kept. Compare them with git diff --cached -- {path}."
                ),
            );
        }
    }
    if untouched.is_empty() {
        return Ok(());
    }
    let mut arguments: Vec<String> = vec!["reset".into(), "--quiet".into(), artifacts.landed_tree.to_string(), "--".into()];
    arguments.extend(untouched.iter().map(|path| format!(":(top,literal){path}")));
    let mut request = GitRequest::new(cwd, &arguments);
    request.index = Some(artifacts.post.to_path_buf());
    request.environment = literal_pathspecs();
    run_git_checked(context, &request)?;
    return Ok(());
}

/// What:
///  Computes the post-index a landing installs.
/// Why:
///   The landing's real index must reflect this commit and every other process's staging at the moment of landing.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await computeLandingPostIndex({ gitPath, cwd, mode, ... });
/// ```
pub fn compute_landing_post_index(
    context: &GitContext,
    cwd: &Path,
    input: &PostIndexInput,
) -> Result<(), GitFailure> {
    let artifacts = Artifacts {
        captured: input.captured_index,
        post: input.post_index,
        landed: input.landed_index,
        landed_tree: input.landed_tree,
    };
    if input.mode == TransactionMode::ExplicitPath {
        copy(input.pre_landing_index, input.post_index)?;
        if !input.committed_paths.is_empty() {
            let mut arguments: Vec<String> = vec!["reset".into(), "--quiet".into(), input.landed_tree.to_string(), "--".into()];
            arguments.extend(input.committed_paths.iter().map(|path| format!(":(top,literal){path}")));
            let mut request = GitRequest::new(cwd, &arguments);
            request.index = Some(input.post_index.to_path_buf());
            request.environment = literal_pathspecs();
            run_git_checked(context, &request)?;
        }
        let outside: Vec<String> = input
            .guarded_paths
            .iter()
            .filter(|path| { return !input.committed_paths.contains(path); })
            .cloned()
            .collect();
        return reset_guarded_paths(context, cwd, &artifacts, &outside);
    }
    if input.exact_private_index && files_equal(input.pre_landing_index, input.captured_index)
        .map_err(|error| { return GitFailure(error.0); })?
    {
        copy(input.landed_index, input.post_index)?;
        return Ok(());
    }
    warn(TAG, "real index changed since invocation; merging landed entries per path");
    copy(input.pre_landing_index, input.post_index)?;
    let mut changed: Vec<String> = changed_paths(context, cwd, input.base_revision, input.landed_tree)?;
    for path in changed_since_captured(context, cwd, input.captured_index, input.landed_tree)? {
        if !changed.contains(&path) {
            changed.push(path);
        }
    }
    return merge_landed_entries(context, cwd, &artifacts, &changed);
}

/// What:
///  Copies one index file with the timestamps Git reads.
/// Why:
///   The post-index keeps the stat data Git uses to skip re-hashing.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await copyIndexFile({ sourcePath, destinationPath });
/// ```
fn copy(source: &Path, destination: &Path) -> Result<(), GitFailure> {
    return copy_index_with_timestamps(source, destination)
        .map_err(|error| { return GitFailure(format!("copying {} failed: {error}", source.display())); });
}

/// Post-index controls stay out of the production code path.
#[cfg(test)]
#[path = "landing_index_tests.rs"]
mod tests;