use std::fs;
use std::num::NonZeroU32;
use std::path::Path;
use crate::change_id::{change_id, ChangeId, ChangeIdPolicy};
use crate::commit_trailers::finalize_cherry_pick_message;
use crate::config::ConfigSet;
use crate::error::{Error, Result};
use crate::merge_file::{ConflictStyle, MergeFavor};
use crate::merge_trees::{
merge_trees_three_way, merge_trees_three_way_cached, MergeTreeCache,
TreeMergeConflictPresentation, WhitespaceMergeOptions,
};
use crate::objects::{parse_commit, CommitData, ObjectId, ObjectKind};
use crate::operation::Conflict;
use crate::porcelain::checkout::checkout_between_trees;
use crate::porcelain::commit::{stamp_change_id_on_new_commit, write_commit_object};
use crate::porcelain::index_from_tree::index_from_tree_with_worktree_stats;
use crate::porcelain::merge::{apply_conflict_merge_worktree, MemOverlayGuard};
use crate::porcelain::replay::ReplayDirection;
use crate::porcelain::reset::{reset_to_commit, ResetMode, ResetOptions};
use crate::porcelain::revert::{merge_commit_message_for_revert, revert_mainline_extra_body};
use crate::porcelain::tree_merge_checkout::{
apply_clean_tree_merge_to_worktree, apply_clean_tree_merge_to_worktree_with_snapshot,
};
use crate::porcelain::worktree_guard::{
load_worktree_snapshot_for_replay, prepare_worktree_for_pick, WorktreeSnapshot,
};
use crate::refs::{update_branch_for_commit_with_config, BranchCommitRefUpdate};
use crate::repo::Repository;
use crate::rev_list::{rev_list, OrderingMode, RevListOptions};
use crate::rev_parse::{resolve_revision_as_commit, try_parse_double_dot_log_range};
use crate::state::{
read_cherry_pick_head, read_merge_msg, read_orig_head, read_revert_head, resolve_head,
HeadState,
};
use crate::stripspace::{process as stripspace_process, Mode as StripspaceMode};
use crate::write_tree::{write_tree_update_index, WriteTreeFlags};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReplayEmptyPolicy {
#[default]
Stop,
Drop,
Keep,
}
#[derive(Debug, Clone)]
pub struct ReplayOptions {
pub mainline: Option<NonZeroU32>,
pub record_origin: bool,
pub no_commit: bool,
pub empty: ReplayEmptyPolicy,
pub message_override: Option<String>,
pub favor: MergeFavor,
pub conflict_style: ConflictStyle,
}
impl Default for ReplayOptions {
fn default() -> Self {
Self {
mainline: None,
record_origin: false,
no_commit: false,
empty: ReplayEmptyPolicy::Stop,
message_override: None,
favor: MergeFavor::default(),
conflict_style: ConflictStyle::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct ReplaySequenceRequest {
pub commits: Vec<ObjectId>,
pub direction: ReplayDirection,
pub committer: String,
pub options: ReplayOptions,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplayCreated {
pub source: ObjectId,
pub new_oid: ObjectId,
pub change_id: Option<ChangeId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SequencePrediction {
Clean {
tree: ObjectId,
dropped: Vec<ObjectId>,
},
Conflicts {
commit: ObjectId,
conflicts: Vec<Conflict>,
},
Empty {
commit: ObjectId,
},
}
pub fn predict_replay_sequence(
repo: &Repository,
req: &ReplaySequenceRequest,
) -> Result<SequencePrediction> {
let (_, head_oid) = branch_head(repo)?;
if req.commits.is_empty() {
let tree = commit_tree(repo, &head_oid)?;
return Ok(SequencePrediction::Clean {
tree,
dropped: Vec::new(),
});
}
if req.commits.len() > 1 && req.options.message_override.is_some() {
return Err(Error::Message(
"message_override is only valid when replaying a single commit".into(),
));
}
let todo = build_todo_from_commits(repo, req.direction, &req.commits)?;
let _overlay = MemOverlayGuard::enter(repo.odb());
predict_sequence(
repo,
head_oid,
req.direction,
&req.options,
&todo,
Vec::new(),
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SequenceOutcome {
Completed {
created: Vec<ReplayCreated>,
dropped: Vec<ObjectId>,
},
Conflicts {
current: ObjectId,
conflicts: Vec<Conflict>,
created_so_far: Vec<ReplayCreated>,
remaining: Vec<ObjectId>,
},
Empty {
current: ObjectId,
created_so_far: Vec<ReplayCreated>,
remaining: Vec<ObjectId>,
},
}
pub fn commits_to_replay(repo: &Repository, specs: &[String]) -> Result<Vec<ObjectId>> {
let mut out = Vec::new();
for spec in specs {
if let Some((exclude, include)) = try_parse_double_dot_log_range(repo, spec)? {
if exclude == include {
continue;
}
let positive = vec![include.to_hex()];
let negative = vec![exclude.to_hex()];
let walk_opts = RevListOptions {
ordering: OrderingMode::Topo,
reverse: false,
use_commit_graph: true,
full_history: true,
..RevListOptions::default()
};
let walk = rev_list(repo, &positive, &negative, &walk_opts)?;
let mut commits = walk.commits;
commits.retain(|oid| *oid != exclude);
if commits.is_empty() {
continue;
}
commits.reverse();
out.extend(commits);
} else {
out.push(resolve_revision_as_commit(repo, spec)?);
}
}
Ok(out)
}
pub fn start_replay_sequence(
repo: &Repository,
req: &ReplaySequenceRequest,
) -> Result<SequenceOutcome> {
if replay_in_progress(repo.git_dir())? {
return Err(Error::Message(
"cherry-pick or revert is already in progress".into(),
));
}
prepare_worktree_for_pick(repo)?;
let (refname, head_oid) = branch_head(repo)?;
if req.commits.is_empty() {
return Ok(SequenceOutcome::Completed {
created: Vec::new(),
dropped: Vec::new(),
});
}
if req.commits.len() > 1 && req.options.message_override.is_some() {
return Err(Error::Message(
"message_override is only valid when replaying a single commit".into(),
));
}
let mut todo = build_todo_from_commits(repo, req.direction, &req.commits)?;
if let Some(first) = todo.first() {
validate_replay_step(repo, req.direction, head_oid, first, &req.options)?;
}
write_sequence_bootstrap(repo, head_oid, &todo, req)?;
run_sequence(
repo,
&refname,
head_oid,
head_oid,
head_oid,
req.direction,
&req.committer,
&req.options,
&mut todo,
Vec::new(),
Vec::new(),
&mut Vec::new(),
None,
)
}
pub fn continue_replay_sequence(repo: &Repository, identity: &str) -> Result<SequenceOutcome> {
let active = load_active_replay(repo)?;
let mut index = repo.load_index()?;
if index.has_unmerged_entries() {
return Err(Error::ReplayContinueUnmerged);
}
let (refname, head_oid) = branch_head(repo)?;
let source = active.current_source()?.ok_or(Error::NoReplayInProgress)?;
let message = resolve_continue_replay_message(repo.git_dir())?;
let new_tree = write_tree_update_index(
repo.odb(),
&mut index,
"",
crate::write_tree::WriteTreeFlags::silent(),
)?;
let allow_empty = active.opts.empty == ReplayEmptyPolicy::Keep;
if !allow_empty {
let head_tree = commit_tree(repo, &head_oid)?;
if new_tree == head_tree {
return Err(Error::NothingToCommit);
}
}
let mut todo = active.todo;
let mut created_records = read_created_records(repo.git_dir())?;
let mut created_so_far: Vec<ObjectId> = created_records.iter().map(|r| r.new_oid).collect();
if active.opts.no_commit {
let head_tree = commit_tree(repo, &head_oid)?;
repo.write_index(&mut index)?;
checkout_between_trees(repo, Some(&head_tree), &new_tree)?;
clear_step_heads(repo.git_dir(), active.direction)?;
} else {
let config = repo.config()?;
let change_id_policy = ChangeIdPolicy::from_config(&config);
let new_oid = write_replay_commit(
repo,
&refname,
head_oid,
identity,
&message,
&source,
active.direction,
new_tree,
allow_empty,
&config,
&change_id_policy,
)?;
created_records.push(replay_created_record(repo, source, new_oid)?);
created_so_far.push(new_oid);
write_created_records(repo.git_dir(), &created_records)?;
clear_step_heads(repo.git_dir(), active.direction)?;
}
if !todo.is_empty() {
todo.remove(0);
}
write_sequencer_todo(repo.git_dir(), &todo)?;
if todo.is_empty() {
clear_replay_state(repo.git_dir())?;
return Ok(SequenceOutcome::Completed {
created: created_records,
dropped: Vec::new(),
});
}
let sequence_start = read_sequencer_head(repo.git_dir()).unwrap_or(active.abort_safety);
let head_after = created_so_far.last().copied().unwrap_or(head_oid);
persist_sequence_checkpoint(
repo,
sequence_start,
head_after,
&todo,
&active.opts,
active.direction,
identity,
&created_records,
)?;
run_sequence(
repo,
&refname,
head_after,
head_after,
sequence_start,
active.direction,
identity,
&active.opts,
&mut todo,
created_so_far,
Vec::new(),
&mut created_records,
None,
)
}
pub fn skip_replay_step(repo: &Repository, identity: &str) -> Result<SequenceOutcome> {
let active = load_active_replay(repo)?;
let (refname, head_oid) = branch_head(repo)?;
reset_index_worktree_to_head(repo, &head_oid)?;
clear_step_heads(repo.git_dir(), active.direction)?;
let mut todo = active.todo;
let dropped = if todo.is_empty() {
None
} else {
Some(todo.remove(0).oid)
};
write_sequencer_todo(repo.git_dir(), &todo)?;
let mut created_records = read_created_records(repo.git_dir())?;
if todo.is_empty() {
clear_replay_state(repo.git_dir())?;
return Ok(SequenceOutcome::Completed {
created: created_records,
dropped: dropped.into_iter().collect(),
});
}
let dropped: Vec<ObjectId> = dropped.into_iter().collect();
let sequence_start = read_sequencer_head(repo.git_dir()).unwrap_or(active.abort_safety);
match run_sequence(
repo,
&refname,
head_oid,
head_oid,
sequence_start,
active.direction,
identity,
&active.opts,
&mut todo,
created_records.iter().map(|r| r.new_oid).collect(),
dropped,
&mut created_records,
None,
)? {
SequenceOutcome::Completed { created, dropped } => {
Ok(SequenceOutcome::Completed { created, dropped })
}
other => Ok(other),
}
}
pub fn abort_replay_sequence(repo: &Repository) -> Result<()> {
let active = load_active_replay(repo)?;
let (refname, head_oid) = branch_head(repo)?;
if head_oid != active.abort_safety {
return Err(Error::ReplayAbortHeadMoved);
}
let restore_oid = read_sequencer_head(repo.git_dir())
.or(read_orig_head(repo.git_dir()).ok().flatten())
.unwrap_or(active.abort_safety);
hard_reset_branch(repo, &refname, restore_oid)?;
clear_replay_state(repo.git_dir())?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TodoVerb {
Pick,
Revert,
}
#[derive(Debug, Clone)]
struct TodoEntry {
verb: TodoVerb,
oid: ObjectId,
subject: String,
}
#[derive(Debug, Clone)]
struct ActiveReplay {
direction: ReplayDirection,
opts: ReplayOptions,
todo: Vec<TodoEntry>,
abort_safety: ObjectId,
}
fn replay_in_progress(git_dir: &Path) -> Result<bool> {
Ok(read_cherry_pick_head(git_dir)?.is_some()
|| read_revert_head(git_dir)?.is_some()
|| git_dir.join("sequencer").join("todo").is_file())
}
fn branch_head(repo: &Repository) -> Result<(String, ObjectId)> {
match resolve_head(repo.git_dir())? {
HeadState::Branch {
refname,
oid: Some(oid),
..
} => Ok((refname, oid)),
HeadState::Branch { .. } => Err(Error::UnbornHead),
HeadState::Detached { .. } => Err(Error::DetachedHead),
HeadState::Invalid => Err(Error::Message("HEAD is in an unknown state".into())),
}
}
fn build_todo_from_commits(
repo: &Repository,
direction: ReplayDirection,
commits: &[ObjectId],
) -> Result<Vec<TodoEntry>> {
let verb = match direction {
ReplayDirection::Pick => TodoVerb::Pick,
ReplayDirection::Revert => TodoVerb::Revert,
};
commits
.iter()
.map(|oid| {
let subject = commit_subject(repo, *oid)?;
Ok(TodoEntry {
verb,
oid: *oid,
subject,
})
})
.collect()
}
fn commit_subject(repo: &Repository, oid: ObjectId) -> Result<String> {
let obj = repo.odb().read(&oid)?;
let commit = parse_commit(&obj.data)?;
Ok(commit
.message
.lines()
.next()
.unwrap_or("")
.trim()
.to_owned())
}
#[allow(clippy::too_many_arguments)]
fn run_sequence(
repo: &Repository,
refname: &str,
head_oid: ObjectId,
abort_safety: ObjectId,
sequence_start: ObjectId,
direction: ReplayDirection,
committer: &str,
opts: &ReplayOptions,
todo: &mut Vec<TodoEntry>,
mut created_so_far: Vec<ObjectId>,
mut dropped: Vec<ObjectId>,
created_records: &mut Vec<ReplayCreated>,
initial_worktree: Option<WorktreeSnapshot>,
) -> Result<SequenceOutcome> {
let mut head = head_oid;
let _ = abort_safety;
let config = repo.config()?;
let change_id_policy = ChangeIdPolicy::from_config(&config);
let mut merge_cache = MergeTreeCache::default();
let mut cached_head_tree = commit_tree(repo, &head)?;
let mut worktree_snapshot = match initial_worktree {
Some(snapshot) => Some(snapshot),
None => repo
.work_tree()
.is_some()
.then(|| load_worktree_snapshot_for_replay(repo))
.transpose()?,
};
while let Some(entry) = todo.first().cloned() {
let step = match apply_one_step(
repo,
refname,
&mut head,
&mut cached_head_tree,
sequence_start,
direction,
committer,
opts,
&entry,
&config,
&change_id_policy,
&mut merge_cache,
&mut worktree_snapshot,
) {
Ok(result) => result,
Err(err) => {
if head != sequence_start || !created_so_far.is_empty() {
let _ = persist_sequence_checkpoint(
repo,
sequence_start,
head,
todo,
opts,
direction,
committer,
created_records,
);
} else {
clear_replay_state(repo.git_dir())?;
}
return Err(err);
}
};
match step {
StepResult::Committed { oid, tree } => {
todo.remove(0);
created_so_far.push(oid);
created_records.push(replay_created_record(repo, entry.oid, oid)?);
cached_head_tree = tree;
merge_cache.clear_retained();
if todo.is_empty() {
clear_replay_state(repo.git_dir())?;
return Ok(SequenceOutcome::Completed {
created: created_records.clone(),
dropped,
});
}
persist_sequence_checkpoint(
repo,
sequence_start,
head,
todo,
opts,
direction,
committer,
created_records,
)?;
}
StepResult::AppliedNoCommit { tree } => {
todo.remove(0);
cached_head_tree = tree;
merge_cache.clear_retained();
if todo.is_empty() {
clear_replay_state(repo.git_dir())?;
return Ok(SequenceOutcome::Completed {
created: created_records.clone(),
dropped,
});
}
persist_sequence_checkpoint(
repo,
sequence_start,
head,
todo,
opts,
direction,
committer,
created_records,
)?;
}
StepResult::DroppedEmpty => {
todo.remove(0);
dropped.push(entry.oid);
continue;
}
StepResult::Conflicts { conflicts } => {
let remaining: Vec<ObjectId> = todo.iter().map(|t| t.oid).collect();
write_sequencer_state(
repo,
sequence_start,
head,
todo,
opts,
direction,
committer,
true,
)?;
record_replay_stop(
repo,
direction,
sequence_start,
entry.oid,
&entry,
opts,
committer,
)?;
write_created_records(repo.git_dir(), created_records)?;
return Ok(SequenceOutcome::Conflicts {
current: entry.oid,
conflicts,
created_so_far: created_records.clone(),
remaining,
});
}
StepResult::EmptyStop => {
let remaining: Vec<ObjectId> = todo.iter().map(|t| t.oid).collect();
write_sequencer_state(
repo,
sequence_start,
head,
todo,
opts,
direction,
committer,
true,
)?;
record_replay_stop(
repo,
direction,
sequence_start,
entry.oid,
&entry,
opts,
committer,
)?;
write_created_records(repo.git_dir(), created_records)?;
return Ok(SequenceOutcome::Empty {
current: entry.oid,
created_so_far: created_records.clone(),
remaining,
});
}
}
}
clear_replay_state(repo.git_dir())?;
Ok(SequenceOutcome::Completed {
created: created_records.clone(),
dropped,
})
}
enum StepResult {
Committed { oid: ObjectId, tree: ObjectId },
AppliedNoCommit { tree: ObjectId },
DroppedEmpty,
Conflicts { conflicts: Vec<Conflict> },
EmptyStop,
}
fn predict_sequence(
repo: &Repository,
head_oid: ObjectId,
direction: ReplayDirection,
opts: &ReplayOptions,
todo: &[TodoEntry],
mut dropped: Vec<ObjectId>,
) -> Result<SequencePrediction> {
let mut head_commit = head_oid;
let mut head_tree = commit_tree(repo, &head_oid)?;
for entry in todo {
match predict_one_step(
repo,
&mut head_commit,
&mut head_tree,
direction,
opts,
entry,
)? {
PredictStepResult::Advanced => {}
PredictStepResult::DroppedEmpty => dropped.push(entry.oid),
PredictStepResult::Conflicts { conflicts } => {
return Ok(SequencePrediction::Conflicts {
commit: entry.oid,
conflicts,
});
}
PredictStepResult::EmptyStop => {
return Ok(SequencePrediction::Empty { commit: entry.oid });
}
}
}
Ok(SequencePrediction::Clean {
tree: head_tree,
dropped,
})
}
enum PredictStepResult {
Advanced,
DroppedEmpty,
Conflicts { conflicts: Vec<Conflict> },
EmptyStop,
}
fn replay_step_presentation<'a>(
entry: &TodoEntry,
head_oid: ObjectId,
source_oid: ObjectId,
opts: &ReplayOptions,
theirs_label: &'a mut String,
base_label: &'a mut String,
) -> TreeMergeConflictPresentation<'a> {
*theirs_label = format!("{} ({})", abbrev_oid(source_oid), entry.subject);
*base_label = format!("parent of {theirs_label}");
TreeMergeConflictPresentation {
label_theirs: crate::merge_trees::TheirsConflictLabel::Fixed(theirs_label),
label_base: base_label,
style: opts.conflict_style,
df_directory_ours_commit: Some(head_oid),
df_directory_theirs_commit: Some(source_oid),
..TreeMergeConflictPresentation::default()
}
}
fn predict_advance_head(repo: &Repository, parent: ObjectId, tree: ObjectId) -> Result<ObjectId> {
let data = CommitData {
tree,
parents: vec![parent],
author: "Grit Predict <predict@local> 0 +0000".to_owned(),
committer: "Grit Predict <predict@local> 0 +0000".to_owned(),
author_raw: Vec::new(),
committer_raw: Vec::new(),
encoding: None,
message: "predict\n".to_owned(),
raw_message: None,
extra_headers: Vec::new(),
};
write_commit_object(repo, &data, Some(false))
}
fn predict_one_step(
repo: &Repository,
head_commit: &mut ObjectId,
head_tree: &mut ObjectId,
direction: ReplayDirection,
opts: &ReplayOptions,
entry: &TodoEntry,
) -> Result<PredictStepResult> {
let source_oid = entry.oid;
if direction == ReplayDirection::Pick && source_oid == *head_commit {
return Err(Error::ReplaySourceAtHead);
}
let source_obj = repo.odb().read(&source_oid)?;
let source = parse_commit(&source_obj.data)?;
let ours_tree = *head_tree;
let empty_compare_tree = *head_tree;
let (base_tree, ours_tree, theirs_tree) = merge_trees_for_replay(
repo,
direction,
&source,
ours_tree,
opts.mainline,
source_oid,
)?;
if direction == ReplayDirection::Pick && base_tree == source.tree {
return match opts.empty {
ReplayEmptyPolicy::Stop => Ok(PredictStepResult::EmptyStop),
ReplayEmptyPolicy::Drop => Ok(PredictStepResult::DroppedEmpty),
ReplayEmptyPolicy::Keep if opts.no_commit => Ok(PredictStepResult::EmptyStop),
ReplayEmptyPolicy::Keep => {
*head_tree = empty_compare_tree;
if !opts.no_commit {
*head_commit = predict_advance_head(repo, *head_commit, empty_compare_tree)?;
}
Ok(PredictStepResult::Advanced)
}
};
}
let mut theirs_label = String::new();
let mut base_label = String::new();
let presentation = replay_step_presentation(
entry,
*head_commit,
source_oid,
opts,
&mut theirs_label,
&mut base_label,
);
let merged = merge_trees_three_way(
repo,
base_tree,
ours_tree,
theirs_tree,
opts.favor,
WhitespaceMergeOptions::default(),
None,
presentation,
)?;
if crate::operation::tree_merge_has_conflicts(&merged) {
return Ok(PredictStepResult::Conflicts {
conflicts: crate::operation::conflicts_from_tree_merge(&merged),
});
}
let new_tree = merged.result_tree;
if new_tree == empty_compare_tree {
return match opts.empty {
ReplayEmptyPolicy::Stop => Ok(PredictStepResult::EmptyStop),
ReplayEmptyPolicy::Drop => Ok(PredictStepResult::DroppedEmpty),
ReplayEmptyPolicy::Keep if opts.no_commit => Ok(PredictStepResult::EmptyStop),
ReplayEmptyPolicy::Keep => {
*head_tree = empty_compare_tree;
if !opts.no_commit {
*head_commit = predict_advance_head(repo, *head_commit, empty_compare_tree)?;
}
Ok(PredictStepResult::Advanced)
}
};
}
*head_tree = new_tree;
if !opts.no_commit {
*head_commit = predict_advance_head(repo, *head_commit, new_tree)?;
}
Ok(PredictStepResult::Advanced)
}
#[allow(clippy::too_many_arguments)]
fn apply_one_step(
repo: &Repository,
refname: &str,
head: &mut ObjectId,
cached_head_tree: &mut ObjectId,
_sequence_start: ObjectId,
direction: ReplayDirection,
committer: &str,
opts: &ReplayOptions,
entry: &TodoEntry,
config: &ConfigSet,
change_id_policy: &ChangeIdPolicy,
merge_cache: &mut MergeTreeCache,
worktree_snapshot: &mut Option<WorktreeSnapshot>,
) -> Result<StepResult> {
let source_oid = entry.oid;
if direction == ReplayDirection::Pick && source_oid == *head {
return Err(Error::ReplaySourceAtHead);
}
let source_obj = repo.odb().read(&source_oid)?;
let source = parse_commit(&source_obj.data)?;
let head_tree = *cached_head_tree;
let ours_tree = if opts.no_commit {
index_stage0_tree(repo)?
} else {
head_tree
};
let (base_tree, ours_tree, theirs_tree) = merge_trees_for_replay(
repo,
direction,
&source,
ours_tree,
opts.mainline,
source_oid,
)?;
if direction == ReplayDirection::Pick && base_tree == source.tree {
return match opts.empty {
ReplayEmptyPolicy::Stop => Ok(StepResult::EmptyStop),
ReplayEmptyPolicy::Drop => Ok(StepResult::DroppedEmpty),
ReplayEmptyPolicy::Keep if opts.no_commit => Ok(StepResult::EmptyStop),
ReplayEmptyPolicy::Keep => {
let message =
replay_message(config, opts, direction, &source, source_oid, committer)?;
let new_oid = write_replay_commit_with_head_tree(
repo,
refname,
*head,
head_tree,
committer,
&message,
&source,
direction,
head_tree,
true,
config,
change_id_policy,
)?;
*head = new_oid;
Ok(StepResult::Committed {
oid: new_oid,
tree: head_tree,
})
}
};
}
let mut theirs_label = String::new();
let mut base_label = String::new();
let presentation = replay_step_presentation(
entry,
*head,
source_oid,
opts,
&mut theirs_label,
&mut base_label,
);
let merged = merge_trees_three_way_cached(
repo,
merge_cache,
base_tree,
ours_tree,
theirs_tree,
opts.favor,
WhitespaceMergeOptions::default(),
None,
presentation,
)?;
if crate::operation::tree_merge_has_conflicts(&merged) {
apply_conflict_merge_worktree(repo, &ours_tree, &merged)?;
return Ok(StepResult::Conflicts {
conflicts: crate::operation::conflicts_from_tree_merge(&merged),
});
}
let new_tree = merged.result_tree;
let empty_compare_tree = if opts.no_commit { ours_tree } else { head_tree };
if new_tree == empty_compare_tree {
return match opts.empty {
ReplayEmptyPolicy::Stop => Ok(StepResult::EmptyStop),
ReplayEmptyPolicy::Drop => Ok(StepResult::DroppedEmpty),
ReplayEmptyPolicy::Keep if opts.no_commit => Ok(StepResult::EmptyStop),
ReplayEmptyPolicy::Keep => {
let message =
replay_message(config, opts, direction, &source, source_oid, committer)?;
let new_oid = write_replay_commit_with_head_tree(
repo,
refname,
*head,
head_tree,
committer,
&message,
&source,
direction,
head_tree,
true,
config,
change_id_policy,
)?;
*head = new_oid;
Ok(StepResult::Committed {
oid: new_oid,
tree: head_tree,
})
}
};
}
if opts.no_commit {
apply_clean_merge_worktree(repo, &ours_tree, &merged, worktree_snapshot)?;
return Ok(StepResult::AppliedNoCommit { tree: new_tree });
}
let message = replay_message(config, opts, direction, &source, source_oid, committer)?;
apply_clean_merge_worktree(repo, &head_tree, &merged, worktree_snapshot)?;
let new_oid = write_replay_commit_with_head_tree(
repo,
refname,
*head,
head_tree,
committer,
&message,
&source,
direction,
new_tree,
false,
config,
change_id_policy,
)?;
*head = new_oid;
Ok(StepResult::Committed {
oid: new_oid,
tree: new_tree,
})
}
fn apply_clean_merge_worktree(
repo: &Repository,
from_tree: &ObjectId,
merged: &crate::merge_trees::TreeMergeOutput,
worktree_snapshot: &mut Option<WorktreeSnapshot>,
) -> Result<()> {
if worktree_snapshot.is_none() {
*worktree_snapshot = Some(load_worktree_snapshot_for_replay(repo)?);
}
match worktree_snapshot.as_mut() {
Some(snapshot) => {
apply_clean_tree_merge_to_worktree_with_snapshot(repo, from_tree, merged, snapshot)
}
None => apply_clean_tree_merge_to_worktree(repo, from_tree, merged),
}
}
fn merge_trees_for_replay(
repo: &Repository,
direction: ReplayDirection,
source: &CommitData,
head_tree: ObjectId,
mainline: Option<NonZeroU32>,
source_oid: ObjectId,
) -> Result<(ObjectId, ObjectId, ObjectId)> {
let parent_tree = |parent: &ObjectId| commit_tree(repo, parent);
let empty = || repo.odb().write(ObjectKind::Tree, &[]);
match direction {
ReplayDirection::Pick => {
let base = if source.parents.is_empty() {
empty()?
} else if source.parents.len() == 1 {
parent_tree(&source.parents[0])?
} else {
let m = mainline.ok_or(Error::MergeCommit { oid: source_oid })?;
let idx = m.get() as usize - 1;
let parent = source
.parents
.get(idx)
.ok_or(Error::MergeCommit { oid: source_oid })?;
parent_tree(parent)?
};
Ok((base, head_tree, source.tree))
}
ReplayDirection::Revert => {
if source.parents.is_empty() {
return Ok((source.tree, head_tree, empty()?));
}
if source.parents.len() == 1 {
let ptree = parent_tree(&source.parents[0])?;
return Ok((source.tree, head_tree, ptree));
}
let m = mainline.ok_or(Error::MergeCommit { oid: source_oid })?;
let idx = m.get() as usize - 1;
let parent = source
.parents
.get(idx)
.copied()
.ok_or(Error::MergeCommit { oid: source_oid })?;
let base = parent_tree(&parent)?;
Ok((source.tree, head_tree, base))
}
}
}
fn replay_message(
config: &ConfigSet,
opts: &ReplayOptions,
direction: ReplayDirection,
source: &CommitData,
source_oid: ObjectId,
committer: &str,
) -> Result<String> {
if let Some(msg) = &opts.message_override {
let mut message = msg.trim().to_owned();
if !message.is_empty() && !message.ends_with('\n') {
message.push('\n');
}
return Ok(message);
}
match direction {
ReplayDirection::Pick => {
let (name, email) = identity_name_email(committer);
Ok(finalize_cherry_pick_message(
&source.message,
opts.record_origin,
false,
name,
email,
config,
&source_oid.to_hex(),
))
}
ReplayDirection::Revert => {
let mainline_parent = (source.parents.len() > 1)
.then(|| {
opts.mainline.and_then(|m| {
let idx = m.get() as usize - 1;
source.parents.get(idx).copied()
})
})
.flatten();
let (title, mut body) =
merge_commit_message_for_revert(source, source_oid, false, '#', None);
if let Some(parent) = mainline_parent {
body.push_str(&revert_mainline_extra_body(source_oid, parent));
}
Ok(format!("{title}\n{body}"))
}
}
}
fn replay_created_record(
repo: &Repository,
source: ObjectId,
new_oid: ObjectId,
) -> Result<ReplayCreated> {
let obj = repo.odb().read(&new_oid)?;
let commit = parse_commit(&obj.data)?;
Ok(ReplayCreated {
source,
new_oid,
change_id: change_id(&commit),
})
}
fn identity_name_email(ident: &str) -> (&str, &str) {
let start = ident.find('<').map(|i| i + 1);
let end = ident.find('>');
match (start, end) {
(Some(s), Some(e)) if e > s => {
let name = ident[..ident.find('<').unwrap_or(0)].trim();
let email = &ident[s..e];
(name, email)
}
_ => ("", ""),
}
}
#[allow(clippy::too_many_arguments)]
fn write_replay_commit(
repo: &Repository,
refname: &str,
head_oid: ObjectId,
committer: &str,
message: &str,
source: &ObjectId,
direction: ReplayDirection,
tree: ObjectId,
allow_empty: bool,
config: &ConfigSet,
change_id_policy: &ChangeIdPolicy,
) -> Result<ObjectId> {
let head_tree = commit_tree(repo, &head_oid)?;
let source_obj = repo.odb().read(source)?;
let source_commit = parse_commit(&source_obj.data)?;
write_replay_commit_with_head_tree(
repo,
refname,
head_oid,
head_tree,
committer,
message,
&source_commit,
direction,
tree,
allow_empty,
config,
change_id_policy,
)
}
#[allow(clippy::too_many_arguments)]
fn write_replay_commit_with_head_tree(
repo: &Repository,
refname: &str,
head_oid: ObjectId,
head_tree: ObjectId,
committer: &str,
message: &str,
source: &CommitData,
direction: ReplayDirection,
tree: ObjectId,
allow_empty: bool,
config: &ConfigSet,
change_id_policy: &ChangeIdPolicy,
) -> Result<ObjectId> {
if !allow_empty && tree == head_tree {
return Err(Error::NothingToCommit);
}
let author = match direction {
ReplayDirection::Pick => source.author.clone(),
ReplayDirection::Revert => committer.to_owned(),
};
let mut commit_data = CommitData {
tree,
parents: vec![head_oid],
author,
committer: committer.to_owned(),
author_raw: Vec::new(),
committer_raw: Vec::new(),
encoding: None,
message: message.to_owned(),
raw_message: None,
extra_headers: Vec::new(),
};
stamp_change_id_on_new_commit(repo, change_id_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 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: committer,
reflog_message: &reflog_msg,
};
update_branch_for_commit_with_config(repo.git_dir(), &ref_update, config)?;
Ok(new_oid)
}
#[allow(clippy::too_many_arguments)]
fn record_replay_stop(
repo: &Repository,
direction: ReplayDirection,
orig_head: ObjectId,
source_oid: ObjectId,
entry: &TodoEntry,
opts: &ReplayOptions,
committer: &str,
) -> Result<()> {
let git_dir = repo.git_dir();
let source_obj = repo.odb().read(&source_oid)?;
let source = parse_commit(&source_obj.data)?;
let config = repo.config()?;
let message = replay_message(&config, opts, direction, &source, source_oid, committer)?;
write_orig_head_if_absent(git_dir, orig_head)?;
fs::write(git_dir.join("MERGE_MSG"), &message).map_err(Error::Io)?;
let head_name = match direction {
ReplayDirection::Pick => "CHERRY_PICK_HEAD",
ReplayDirection::Revert => "REVERT_HEAD",
};
fs::write(
git_dir.join(head_name),
format!("{}\n", source_oid.to_hex()),
)
.map_err(Error::Io)?;
let _ = entry;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn write_sequencer_state(
repo: &Repository,
sequence_start: ObjectId,
current_head: ObjectId,
todo: &[TodoEntry],
opts: &ReplayOptions,
direction: ReplayDirection,
committer: &str,
rewrite_opts: bool,
) -> Result<()> {
let git_dir = repo.git_dir();
fs::create_dir_all(git_dir.join("sequencer")).map_err(Error::Io)?;
fs::write(
git_dir.join("sequencer").join("head"),
format!("{}\n", sequence_start.to_hex()),
)
.map_err(Error::Io)?;
fs::write(
git_dir.join("sequencer").join("abort-safety"),
format!("{}\n", current_head.to_hex()),
)
.map_err(Error::Io)?;
write_sequencer_todo(git_dir, todo)?;
if rewrite_opts {
write_persisted_options(
git_dir,
&ReplaySequenceRequest {
commits: Vec::new(),
direction,
committer: committer.to_owned(),
options: opts.clone(),
},
)?;
}
Ok(())
}
fn write_sequencer_todo(git_dir: &Path, todo: &[TodoEntry]) -> Result<()> {
if todo.is_empty() {
return Ok(());
}
fs::create_dir_all(git_dir.join("sequencer")).map_err(Error::Io)?;
let mut lines = String::new();
for entry in todo {
let cmd = match entry.verb {
TodoVerb::Pick => "pick",
TodoVerb::Revert => "revert",
};
let abbrev = abbrev_oid(entry.oid);
lines.push_str(&format!("{cmd} {abbrev} {}\n", entry.subject));
}
fs::write(git_dir.join("sequencer").join("todo"), lines).map_err(Error::Io)?;
Ok(())
}
fn abbrev_oid(oid: ObjectId) -> String {
oid.to_hex()[..7].to_owned()
}
fn write_persisted_options(git_dir: &Path, req: &ReplaySequenceRequest) -> Result<()> {
fs::create_dir_all(git_dir.join("sequencer")).map_err(Error::Io)?;
let mut git_opts = String::new();
if req.options.record_origin {
git_opts.push_str("[options]\n\trecord-origin = true\n");
fs::write(git_dir.join("sequencer").join("opts"), git_opts).map_err(Error::Io)?;
}
let empty = match req.options.empty {
ReplayEmptyPolicy::Stop => "stop",
ReplayEmptyPolicy::Drop => "drop",
ReplayEmptyPolicy::Keep => "keep",
};
let direction = match req.direction {
ReplayDirection::Pick => "pick",
ReplayDirection::Revert => "revert",
};
let favor = match req.options.favor {
MergeFavor::None => "none",
MergeFavor::Ours => "ours",
MergeFavor::Theirs => "theirs",
MergeFavor::Union => "union",
};
let style = match req.options.conflict_style {
ConflictStyle::Merge => "merge",
ConflictStyle::Diff3 => "diff3",
ConflictStyle::ZealousDiff3 => "zealous-diff3",
};
let mainline = req
.options
.mainline
.map(|m| m.get().to_string())
.unwrap_or_default();
let mut body = String::new();
body.push_str(&format!("direction = {direction}\n"));
body.push_str(&format!("record-origin = {}\n", req.options.record_origin));
body.push_str(&format!("no-commit = {}\n", req.options.no_commit));
body.push_str(&format!("empty = {empty}\n"));
body.push_str(&format!("favor = {favor}\n"));
body.push_str(&format!("conflict-style = {style}\n"));
body.push_str(&format!("mainline = {mainline}\n"));
body.push_str(&format!("committer = {}\n", req.committer));
if let Some(msg) = &req.options.message_override {
body.push_str(&format!("message-override = {msg}\n"));
}
fs::write(git_dir.join("sequencer").join("grit-opts"), body).map_err(Error::Io)?;
Ok(())
}
fn load_active_replay(repo: &Repository) -> Result<ActiveReplay> {
let git_dir = repo.git_dir();
let todo_path = git_dir.join("sequencer").join("todo");
let todo = if todo_path.is_file() {
parse_sequencer_todo(repo, &todo_path)?
} else if let Some(oid) = read_cherry_pick_head(git_dir)? {
vec![TodoEntry {
verb: TodoVerb::Pick,
oid,
subject: commit_subject(repo, oid)?,
}]
} else if let Some(oid) = read_revert_head(git_dir)? {
vec![TodoEntry {
verb: TodoVerb::Revert,
oid,
subject: commit_subject(repo, oid)?,
}]
} else {
return Err(Error::NoReplayInProgress);
};
let abort_safety = read_sequencer_abort_safety(git_dir)
.or_else(|| read_orig_head(git_dir).ok().flatten())
.or_else(|| resolve_head(git_dir).ok().and_then(|h| h.oid().copied()))
.ok_or(Error::NoReplayInProgress)?;
let (direction, opts) = load_persisted_options(git_dir, &todo)?;
Ok(ActiveReplay {
direction,
opts,
todo,
abort_safety,
})
}
impl ActiveReplay {
fn current_source(&self) -> Result<Option<ObjectId>> {
Ok(self.todo.first().map(|t| t.oid))
}
}
fn read_sequencer_head(git_dir: &Path) -> Option<ObjectId> {
read_oid_file(&git_dir.join("sequencer").join("head"))
}
fn read_sequencer_abort_safety(git_dir: &Path) -> Option<ObjectId> {
read_oid_file(&git_dir.join("sequencer").join("abort-safety"))
}
fn read_oid_file(path: &Path) -> Option<ObjectId> {
fs::read_to_string(path)
.ok()
.and_then(|s| ObjectId::from_hex(s.trim()).ok())
}
fn parse_sequencer_todo(repo: &Repository, path: &Path) -> Result<Vec<TodoEntry>> {
let content = fs::read_to_string(path).map_err(Error::Io)?;
let mut out = Vec::new();
for line in content.lines() {
let t = line.trim();
if t.is_empty() || t.starts_with('#') {
continue;
}
let mut parts = t.split_whitespace();
let cmd = parts.next().ok_or_else(|| Error::SequencerUnknownCommand {
command: String::new(),
})?;
let (verb, direction_hint) = match cmd {
"pick" | "p" => (TodoVerb::Pick, ReplayDirection::Pick),
"revert" | "r" => (TodoVerb::Revert, ReplayDirection::Revert),
other => {
return Err(Error::SequencerUnknownCommand {
command: other.to_owned(),
});
}
};
let oid_token = parts.next().ok_or_else(|| Error::SequencerUnknownCommand {
command: cmd.to_owned(),
})?;
let oid =
repo.rev_walk()
.resolve(oid_token)
.map_err(|_| Error::SequencerUnknownCommand {
command: cmd.to_owned(),
})?;
let subject = parts.collect::<Vec<_>>().join(" ");
let subject = if subject.is_empty() {
commit_subject(repo, oid)?
} else {
subject
};
let _ = direction_hint;
out.push(TodoEntry { verb, oid, subject });
}
if out.is_empty() {
return Err(Error::NoReplayInProgress);
}
Ok(out)
}
fn load_persisted_options(
git_dir: &Path,
todo: &[TodoEntry],
) -> Result<(ReplayDirection, ReplayOptions)> {
let path = git_dir.join("sequencer").join("grit-opts");
let mut opts = ReplayOptions::default();
let mut direction = todo
.first()
.map(|e| match e.verb {
TodoVerb::Pick => ReplayDirection::Pick,
TodoVerb::Revert => ReplayDirection::Revert,
})
.unwrap_or(ReplayDirection::Pick);
if read_cherry_pick_head(git_dir)?.is_some() {
direction = ReplayDirection::Pick;
} else if read_revert_head(git_dir)?.is_some() {
direction = ReplayDirection::Revert;
}
apply_git_sequencer_opts(git_dir, &mut opts);
if !path.is_file() {
return Ok((direction, opts));
}
let content = fs::read_to_string(&path).map_err(Error::Io)?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim();
match key {
"direction" => {
direction = if value == "revert" {
ReplayDirection::Revert
} else {
ReplayDirection::Pick
};
}
"record-origin" => opts.record_origin = value == "true",
"no-commit" => opts.no_commit = value == "true",
"empty" => {
opts.empty = match value {
"drop" => ReplayEmptyPolicy::Drop,
"keep" => ReplayEmptyPolicy::Keep,
_ => ReplayEmptyPolicy::Stop,
};
}
"favor" => {
opts.favor = match value {
"ours" => MergeFavor::Ours,
"theirs" => MergeFavor::Theirs,
"union" => MergeFavor::Union,
_ => MergeFavor::None,
};
}
"conflict-style" => {
opts.conflict_style = match value {
"diff3" => ConflictStyle::Diff3,
"zealous-diff3" => ConflictStyle::ZealousDiff3,
_ => ConflictStyle::Merge,
};
}
"mainline" => {
if value.is_empty() {
opts.mainline = None;
} else if let Ok(n) = value.parse::<u32>() {
opts.mainline = NonZeroU32::new(n);
}
}
"message-override" if !value.is_empty() => {
opts.message_override = Some(value.to_owned());
}
_ => {}
}
}
Ok((direction, opts))
}
fn clear_step_heads(git_dir: &Path, direction: ReplayDirection) -> Result<()> {
let name = match direction {
ReplayDirection::Pick => "CHERRY_PICK_HEAD",
ReplayDirection::Revert => "REVERT_HEAD",
};
let path = git_dir.join(name);
if path.exists() {
fs::remove_file(path).map_err(Error::Io)?;
}
Ok(())
}
fn resolve_continue_replay_message(git_dir: &Path) -> Result<String> {
let Some(raw) = read_merge_msg(git_dir)? else {
return Err(Error::NoReplayInProgress);
};
let processed = stripspace_process(
raw.as_bytes(),
&StripspaceMode::StripComments("#".to_owned()),
);
let message = String::from_utf8(processed).map_err(|_| Error::NothingToCommit)?;
if message.trim().is_empty() {
return Err(Error::NothingToCommit);
}
let mut message = message;
if !message.ends_with('\n') {
message.push('\n');
}
Ok(message)
}
fn write_orig_head_if_absent(git_dir: &Path, oid: ObjectId) -> Result<()> {
let path = git_dir.join("ORIG_HEAD");
if path.exists() {
return Ok(());
}
fs::write(path, format!("{}\n", oid.to_hex())).map_err(Error::Io)
}
#[allow(clippy::too_many_arguments)]
fn persist_sequence_checkpoint(
repo: &Repository,
sequence_start: ObjectId,
current_head: ObjectId,
todo: &[TodoEntry],
opts: &ReplayOptions,
direction: ReplayDirection,
committer: &str,
created_records: &[ReplayCreated],
) -> Result<()> {
write_sequencer_state(
repo,
sequence_start,
current_head,
todo,
opts,
direction,
committer,
false,
)?;
write_created_records(repo.git_dir(), created_records)
}
fn validate_replay_step(
repo: &Repository,
direction: ReplayDirection,
head: ObjectId,
entry: &TodoEntry,
opts: &ReplayOptions,
) -> Result<()> {
if direction == ReplayDirection::Pick && entry.oid == head {
return Err(Error::ReplaySourceAtHead);
}
let source_obj = repo.odb().read(&entry.oid)?;
let source = parse_commit(&source_obj.data)?;
if source.parents.len() > 1 && opts.mainline.is_none() {
return Err(Error::MergeCommit { oid: entry.oid });
}
Ok(())
}
fn write_sequence_bootstrap(
repo: &Repository,
head_oid: ObjectId,
todo: &[TodoEntry],
req: &ReplaySequenceRequest,
) -> Result<()> {
write_orig_head_if_absent(repo.git_dir(), head_oid)?;
write_sequencer_state(
repo,
head_oid,
head_oid,
todo,
&req.options,
req.direction,
&req.committer,
true,
)?;
write_created_records(repo.git_dir(), &[])?;
Ok(())
}
fn write_created_records(git_dir: &Path, records: &[ReplayCreated]) -> Result<()> {
let seq = git_dir.join("sequencer");
if records.is_empty() && !seq.is_dir() {
return Ok(());
}
fs::create_dir_all(&seq).map_err(Error::Io)?;
let mut body = String::new();
for record in records {
body.push_str(&format!(
"{} {}\n",
record.source.to_hex(),
record.new_oid.to_hex()
));
}
fs::write(seq.join("created"), body).map_err(Error::Io)
}
fn read_created_records(git_dir: &Path) -> Result<Vec<ReplayCreated>> {
let path = git_dir.join("sequencer").join("created");
if !path.is_file() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path).map_err(Error::Io)?;
let mut out = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.split_whitespace();
let src = parts.next().ok_or_else(|| {
Error::Message("invalid sequencer/created line: missing source oid".into())
})?;
let new = parts.next().ok_or_else(|| {
Error::Message("invalid sequencer/created line: missing new oid".into())
})?;
let source = ObjectId::from_hex(src)
.map_err(|_| Error::Message("invalid sequencer/created line: bad source oid".into()))?;
let new_oid = ObjectId::from_hex(new)
.map_err(|_| Error::Message("invalid sequencer/created line: bad new oid".into()))?;
out.push(ReplayCreated {
source,
new_oid,
change_id: None,
});
}
Ok(out)
}
fn clear_replay_state(git_dir: &Path) -> Result<()> {
for name in ["CHERRY_PICK_HEAD", "REVERT_HEAD", "MERGE_MSG"] {
let path = git_dir.join(name);
if path.exists() {
fs::remove_file(path).map_err(Error::Io)?;
}
}
let seq = git_dir.join("sequencer");
if seq.is_dir() {
fs::remove_dir_all(seq).map_err(Error::Io)?;
}
Ok(())
}
fn reset_index_worktree_to_head(repo: &Repository, head_oid: &ObjectId) -> Result<()> {
let head_tree = commit_tree(repo, head_oid)?;
let mut index = index_from_tree_with_worktree_stats(repo, &head_tree)?;
repo.write_index(&mut index)?;
checkout_between_trees(repo, None, &head_tree)?;
Ok(())
}
fn hard_reset_branch(repo: &Repository, refname: &str, target: ObjectId) -> Result<()> {
let short = refname.strip_prefix("refs/heads/").unwrap_or(refname);
let _ = refname;
reset_to_commit(
repo,
&ResetOptions::new(
target,
ResetMode::Hard,
"Grit <grit@local> 0 +0000",
"abort replay",
),
)?;
let _ = short;
Ok(())
}
fn index_stage0_tree(repo: &Repository) -> Result<ObjectId> {
let mut index = repo.load_index()?;
write_tree_update_index(repo.odb(), &mut index, "", WriteTreeFlags::silent())
}
fn apply_git_sequencer_opts(git_dir: &Path, opts: &mut ReplayOptions) {
let path = git_dir.join("sequencer").join("opts");
let Ok(content) = fs::read_to_string(&path) else {
return;
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim();
if key == "record-origin" && value == "true" {
opts.record_origin = true;
}
}
}
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)
}