grit-lib 0.5.18

Core library for the grit Git implementation
//! Single-commit replay (cherry-pick and revert) onto the current branch.
//!
//! [`replay_commit`] performs a three-way merge, updates the working tree and
//! index on success, writes a new commit, and advances the checked-out branch
//! through [`crate::refs::update_branch_for_commit_with_config`]. On conflict or
//! when the replay would be a no-op (`Empty`, `AlreadyApplied`), nothing in the
//! worktree, index, or refs is changed.

use std::fs;

use crate::change_id::{change_id, ChangeId, ChangeIdPolicy};
use crate::config::ConfigSet;
use crate::error::{Error, Result};
use crate::merge_file::MergeFavor;
use crate::merge_trees::{
    merge_trees_three_way, TreeMergeConflictPresentation, WhitespaceMergeOptions,
};
use crate::objects::{parse_commit, CommitData, ObjectId, ObjectKind};
use crate::porcelain::checkout::checkout_between_trees;
use crate::porcelain::commit::{stamp_change_id_on_new_commit, write_commit_object};
use crate::porcelain::revert::merge_commit_message_for_revert;
use crate::porcelain::tree_merge_checkout::apply_clean_tree_merge_to_worktree;
use crate::porcelain::worktree_guard::ensure_worktree_clean_for_pick;
use crate::refs::{
    resolve_ref, should_autocreate_reflog_with_config, update_branch_for_commit_with_config,
    BranchCommitRefLock, BranchCommitRefUpdate,
};
use crate::repo::Repository;
use crate::state::{resolve_head, HeadState};

/// Whether [`replay_commit`] applies a commit's patch or its inverse.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplayDirection {
    /// Cherry-pick: apply the diff from the commit's first parent to the commit.
    Pick,
    /// Revert: apply the inverse of that diff.
    Revert,
}

/// Inputs for [`replay_commit`].
#[derive(Debug, Clone)]
pub struct ReplayRequest {
    /// Commit object to replay (at most one parent; root commits are allowed for pick).
    pub commit: ObjectId,
    /// Pick or revert semantics for the three-way merge and commit message.
    pub direction: ReplayDirection,
    /// Committer identity in Git header form: `Name <email> <unix-time> <tz>`.
    pub committer: String,
    /// When set, replaces the default commit message (pick: source message; revert: revert template).
    pub message_override: Option<String>,
}

/// Result of [`replay_commit`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplayOutcome {
    /// A new commit was created and the branch was advanced.
    Committed {
        /// OID of the new commit.
        oid: ObjectId,
        /// Tree OID stored in the new commit.
        tree: ObjectId,
        /// Stored `change-id` header on the new commit when present.
        change_id: Option<ChangeId>,
    },
    /// The three-way merge had conflicts; repository state is unchanged.
    Conflicts {
        /// Conflicted paths in repository-relative form, sorted and deduplicated.
        paths: Vec<String>,
    },
    /// The commit introduces no tree change relative to its first parent (pick only).
    Empty,
    /// The merged tree matches the current `HEAD` tree; nothing to commit.
    AlreadyApplied,
}

/// Replay `req.commit` onto the current branch using pick or revert merge semantics.
///
/// Requires a clean worktree (same checks as `grit pick`), a branch `HEAD`, and a
/// resolvable first-parent chain for the source commit. The source must not be a
/// merge commit.
///
/// Successful pick and revert commits receive a fresh [`change-id`](crate::change_id::change_id);
/// the source commit's id is not copied. When conflicted replay with `--continue` is
/// added later, the concluding commit must assign a new id the same way.
///
/// # Errors
///
/// - [`Error::DetachedHead`] when `HEAD` is not on a branch.
/// - [`Error::UnbornHead`] when the branch has no commits yet.
/// - [`Error::MergeCommit`] when the source commit has more than one parent (pick or revert).
/// - [`Error::ReplaySourceAtHead`] when picking a commit that is already `HEAD`.
/// - I/O, ODB, merge, checkout, or ref update failures.
pub fn replay_commit(repo: &Repository, req: &ReplayRequest) -> Result<ReplayOutcome> {
    ensure_worktree_clean_for_pick(repo)?;

    let (refname, head_oid) = match resolve_head(repo.git_dir())? {
        HeadState::Branch {
            refname,
            oid: Some(oid),
            ..
        } => (refname, oid),
        HeadState::Branch { .. } => return Err(Error::UnbornHead),
        HeadState::Detached { .. } => return Err(Error::DetachedHead),
        HeadState::Invalid => {
            return Err(Error::Message("HEAD is in an unknown state".into()));
        }
    };

    if req.direction == ReplayDirection::Pick && req.commit == head_oid {
        return Err(Error::ReplaySourceAtHead);
    }

    let source_obj = repo.odb().read(&req.commit)?;
    let source = parse_commit(&source_obj.data)?;
    if source.parents.len() > 1 {
        return Err(Error::MergeCommit { oid: req.commit });
    }

    let head_tree = commit_tree(repo, &head_oid)?;
    let (base_tree, ours_tree, theirs_tree) =
        merge_trees_for_direction(repo, req.direction, &source, head_tree)?;

    if req.direction == ReplayDirection::Pick && base_tree == source.tree {
        return Ok(ReplayOutcome::Empty);
    }

    let presentation = TreeMergeConflictPresentation {
        df_directory_ours_commit: Some(head_oid),
        df_directory_theirs_commit: Some(req.commit),
        ..TreeMergeConflictPresentation::default()
    };
    let merged = merge_trees_three_way(
        repo,
        base_tree,
        ours_tree,
        theirs_tree,
        MergeFavor::default(),
        WhitespaceMergeOptions::default(),
        None,
        presentation,
    )?;

    if crate::operation::tree_merge_has_conflicts(&merged) {
        let conflicts = crate::operation::conflicts_from_tree_merge(&merged);
        let paths: Vec<String> = conflicts.iter().map(|c| c.path.clone()).collect();
        return Ok(ReplayOutcome::Conflicts { paths });
    }

    let new_tree = merged.result_tree;
    if new_tree == head_tree {
        return Ok(ReplayOutcome::AlreadyApplied);
    }

    let config = repo.config()?;
    let message = commit_message(req, &source)?;
    let author = match req.direction {
        ReplayDirection::Pick => source.author.clone(),
        ReplayDirection::Revert => req.committer.clone(),
    };

    let mut commit_data = CommitData {
        tree: new_tree,
        parents: vec![head_oid],
        author,
        committer: req.committer.clone(),
        author_raw: Vec::new(),
        committer_raw: Vec::new(),
        encoding: None,
        message,
        raw_message: None,
        extra_headers: Vec::new(),
    };
    let policy = ChangeIdPolicy::from_config(config.as_ref());
    stamp_change_id_on_new_commit(repo, &policy, &mut commit_data)?;
    let new_oid = write_commit_object(repo, &commit_data, None)?;

    let subject = commit_data.message.lines().next().unwrap_or("").trim_end();
    let reflog_msg = match req.direction {
        ReplayDirection::Pick => format!("cherry-pick: {subject}"),
        ReplayDirection::Revert => format!("revert: {subject}"),
    };
    let ref_update = BranchCommitRefUpdate {
        branch_ref: &refname,
        expected_old: Some(head_oid),
        new_oid,
        identity: &req.committer,
        reflog_message: &reflog_msg,
    };

    let index_snapshot = fs::read(repo.index_path()).ok();

    if crate::refs::store::open_ref_store(repo.git_dir())?.format()
        != crate::refs::store::RefStorageFormat::Files
    {
        let current = resolve_ref(repo.git_dir(), &refname).ok();
        crate::refs::verify_branch_commit_cas(Some(head_oid), current, &refname)?;

        apply_clean_tree_merge_to_worktree(repo, &head_tree, &merged)?;

        if let Err(err) =
            update_branch_for_commit_with_config(repo.git_dir(), &ref_update, config.as_ref())
        {
            undo_prepared_branch_commit_reflogs(repo.git_dir(), &refname, config.as_ref());
            restore_worktree_after_failed_ref_commit(
                repo,
                &head_tree,
                &new_tree,
                index_snapshot.as_deref(),
            )?;
            return Err(err);
        }
    } else {
        let mut branch_ref_lock =
            BranchCommitRefLock::acquire(repo.git_dir(), &refname, Some(head_oid))?;
        branch_ref_lock.write_reflogs(repo.git_dir(), &ref_update, &head_oid, config.as_ref())?;

        apply_clean_tree_merge_to_worktree(repo, &head_tree, &merged)?;

        if let Err(err) = branch_ref_lock.finish_ref_commit(repo.git_dir(), &ref_update) {
            undo_prepared_branch_commit_reflogs(repo.git_dir(), &refname, config.as_ref());
            restore_worktree_after_failed_ref_commit(
                repo,
                &head_tree,
                &new_tree,
                index_snapshot.as_deref(),
            )?;
            return Err(err);
        }
    }

    Ok(ReplayOutcome::Committed {
        oid: new_oid,
        tree: new_tree,
        change_id: change_id(&commit_data),
    })
}

fn undo_prepared_branch_commit_reflogs(
    git_dir: &std::path::Path,
    branch_ref: &str,
    config: &ConfigSet,
) {
    if should_autocreate_reflog_with_config(config, git_dir, branch_ref) {
        let _ = crate::reflog::truncate_last_reflog_line(git_dir, branch_ref);
    }
    if should_autocreate_reflog_with_config(config, git_dir, "HEAD") {
        let _ = crate::reflog::truncate_last_reflog_line(git_dir, "HEAD");
    }
}

fn restore_worktree_after_failed_ref_commit(
    repo: &Repository,
    head_tree: &ObjectId,
    checked_out_tree: &ObjectId,
    index_snapshot: Option<&[u8]>,
) -> Result<()> {
    checkout_between_trees(repo, Some(checked_out_tree), head_tree)?;
    if let Some(bytes) = index_snapshot {
        fs::write(repo.index_path(), bytes)?;
    }
    Ok(())
}

fn merge_trees_for_direction(
    repo: &Repository,
    direction: ReplayDirection,
    source: &CommitData,
    head_tree: ObjectId,
) -> Result<(ObjectId, ObjectId, ObjectId)> {
    let parent_tree = if let Some(parent) = source.parents.first() {
        commit_tree(repo, parent)?
    } else {
        empty_tree(repo)?
    };
    let source_tree = source.tree;
    Ok(match direction {
        ReplayDirection::Pick => (parent_tree, head_tree, source_tree),
        ReplayDirection::Revert => (source_tree, head_tree, parent_tree),
    })
}

fn commit_message(req: &ReplayRequest, source: &CommitData) -> Result<String> {
    if let Some(msg) = &req.message_override {
        let mut message = msg.trim().to_owned();
        if !message.is_empty() && !message.ends_with('\n') {
            message.push('\n');
        }
        return Ok(message);
    }
    match req.direction {
        ReplayDirection::Pick => Ok(source.message.clone()),
        ReplayDirection::Revert => {
            let (title, body) =
                merge_commit_message_for_revert(source, req.commit, false, '#', None);
            Ok(format!("{title}\n{body}"))
        }
    }
}

fn commit_tree(repo: &Repository, commit_oid: &ObjectId) -> Result<ObjectId> {
    let obj = repo.odb().read(commit_oid)?;
    if obj.kind != ObjectKind::Commit {
        return Err(Error::CorruptObject(format!(
            "expected commit, got {}",
            obj.kind.as_str()
        )));
    }
    Ok(parse_commit(&obj.data)?.tree)
}

fn empty_tree(repo: &Repository) -> Result<ObjectId> {
    repo.odb().write(ObjectKind::Tree, &[])
}