use crate::diagnostic_log::debug;
use crate::inner_git_words::record_words;
use crate::merge_isolation_journal::{
MergeIntent, list_merge_intents, parse_merge_intent, remove_merge_intent,
};
use crate::merge_isolation_moved::settle_moved_branch;
use crate::merge_isolation_changes::{
Change, WorktreeState, parse_raw_changes, worktree_path, worktree_state,
};
use crate::recovery_error::{RecoveryError, io_failure};
use crate::transaction_owner::{OwnerLiveness, OwnerLivenessError, classify_transaction_owner_record};
use crate::transaction_git::{GitContext, GitRequest, run_git, run_git_checked};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug)]
pub struct RecoveryPlace {
pub git: GitContext,
pub cwd: PathBuf,
pub common_dir: PathBuf,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MergeRecoveryAction {
OwnerActive,
Discarded,
Completed,
AlreadyLanded,
BranchMoved,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MergeRecoveryOutcome {
pub record: PathBuf,
pub action: MergeRecoveryAction,
}
pub fn merge_intents_to_recover(git_dir: &Path) -> Result<bool, RecoveryError> {
for record in list_merge_intents(git_dir)? {
let bytes: Vec<u8> = match std::fs::read(record.as_path()) {
Ok(found) => found,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(io_failure("reading", record.as_path(), &error)),
};
let Some(intent) = parse_merge_intent(bytes.as_slice()) else {
return Ok(true);
};
if owner_liveness(&intent, record.as_path())? == OwnerLiveness::Dead {
return Ok(true);
}
}
return Ok(false);
}
pub fn recover_interrupted_merges(
place: &RecoveryPlace,
git_dir: &Path,
) -> Result<Vec<MergeRecoveryOutcome>, RecoveryError> {
let mut outcomes: Vec<MergeRecoveryOutcome> = Vec::new();
for record in list_merge_intents(git_dir)? {
let bytes: Vec<u8> = match std::fs::read(record.as_path()) {
Ok(found) => found,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(io_failure("reading", record.as_path(), &error)),
};
let Some(intent) = parse_merge_intent(bytes.as_slice()) else {
return Err(RecoveryError(format!(
"The merge record {} is malformed. It was left in place; if no merge is running in this worktree, \
remove it, and check the branch and `git status` before merging again.",
record.display()
)));
};
let action: MergeRecoveryAction = recover_one(place, record.as_path(), &intent)?;
outcomes.push(MergeRecoveryOutcome { record, action });
}
return Ok(outcomes);
}
fn owner_liveness(intent: &MergeIntent, record: &Path) -> Result<OwnerLiveness, RecoveryError> {
match classify_transaction_owner_record(&intent.owner, record) {
Ok(found) => return Ok(found),
Err(OwnerLivenessError::Probe(error)) if error.raw_os_error() == Some(libc::EPERM) => {
return Ok(OwnerLiveness::Alive);
}
Err(OwnerLivenessError::Probe(error)) => {
return Err(RecoveryError(format!(
"probing the owner {} of the merge record {} failed: {error}",
intent.owner.owner_pid,
record.display()
)));
}
Err(OwnerLivenessError::ExpiredForeign(text)) => return Err(RecoveryError(text)),
Err(OwnerLivenessError::Identity(error)) => {
return Err(RecoveryError(format!(
"reading the identity of the owner {} of the merge record {} failed: {error}",
intent.owner.owner_pid,
record.display()
)));
}
}
}
fn resolve_commit(place: &RecoveryPlace, root: &Path, reference: &str) -> Result<Option<String>, RecoveryError> {
let revision: String = format!("{reference}^{{commit}}");
let request: GitRequest = GitRequest::new(root, &["rev-parse", "--verify", "--quiet", revision.as_str()]);
let output = run_git(&place.git, &request)
.map_err(|error| { return RecoveryError(format!("git rev-parse could not start: {error}")); })?;
if !output.succeeded() {
return Ok(None);
}
return Ok(Some(String::from_utf8_lossy(&output.stdout).trim().to_owned()));
}
pub(crate) fn merge_changes(place: &RecoveryPlace, root: &Path, intent: &MergeIntent) -> Result<Vec<Change>, RecoveryError> {
let request: GitRequest = GitRequest::new(
root,
&["diff-tree", "-r", "-z", "--raw", "--no-renames", "--no-abbrev", intent.old_oid.as_str(), intent.new_oid.as_str()],
);
let output = run_git_checked(&place.git, &request).map_err(|error| { return RecoveryError(error.to_string()); })?;
return parse_raw_changes(output.stdout.as_slice()).map_err(RecoveryError);
}
pub(crate) fn index_holds_the_merge(
place: &RecoveryPlace,
root: &Path,
old_oid: &str,
changes: &[Change],
) -> Result<bool, RecoveryError> {
let request: GitRequest = GitRequest::new(
root,
&["diff-index", "--cached", "-z", "--raw", "--no-renames", "--no-abbrev", old_oid, "--"],
);
let output = run_git_checked(&place.git, &request).map_err(|error| { return RecoveryError(error.to_string()); })?;
let staged: Vec<Change> = parse_raw_changes(output.stdout.as_slice()).map_err(RecoveryError)?;
for change in changes {
let Some(entry) = staged.iter().find(|candidate| { return candidate.path == change.path; }) else {
continue;
};
let merged_entry: bool = entry.new_mode == change.new_mode && entry.new_oid == change.new_oid;
if merged_entry {
return Ok(true);
}
}
return Ok(false);
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Standing {
Old,
New,
Foreign,
}
pub(crate) fn standing(change: &Change, state: &WorktreeState) -> Standing {
let old_matches: bool = match state {
WorktreeState::Missing => !change.existed(),
WorktreeState::Content(oid) => change.existed() && *oid == change.old_oid,
WorktreeState::Other => false,
};
if old_matches {
return Standing::Old;
}
let new_matches: bool = match state {
WorktreeState::Missing => !change.exists_after(),
WorktreeState::Content(oid) => change.exists_after() && *oid == change.new_oid,
WorktreeState::Other => false,
};
if new_matches {
return Standing::New;
}
return Standing::Foreign;
}
fn recover_one(place: &RecoveryPlace, record: &Path, intent: &MergeIntent) -> Result<MergeRecoveryAction, RecoveryError> {
if owner_liveness(intent, record)? == OwnerLiveness::Alive {
debug("recoverInterruptedMerges", format!("the owner of {} still runs", record.display()).as_str());
return Ok(MergeRecoveryAction::OwnerActive);
}
return settle_failed_merge(place, record, intent);
}
pub fn settle_failed_merge(
place: &RecoveryPlace,
record: &Path,
intent: &MergeIntent,
) -> Result<MergeRecoveryAction, RecoveryError> {
let root: &Path = Path::new(intent.repository_root.as_str());
let target: &str = intent.target_ref.as_deref().unwrap_or("HEAD");
let current: Option<String> = resolve_commit(place, root, target)?;
if current.as_deref() == Some(intent.new_oid.as_str()) {
remove_record(record)?;
return Ok(MergeRecoveryAction::AlreadyLanded);
}
match current.as_deref() {
Some(head) if head == intent.old_oid => {}
Some(head) => return settle_moved_branch(place, record, intent, head),
None => {
return Err(RecoveryError(format!(
"{target} names no commit although the interrupted merge in {} expected {}. Nothing was changed and the \
record {} was kept. Check `git status` and `git log {target}`; remove the record when {target} is as you \
want it.",
root.display(),
intent.old_oid,
record.display()
)));
}
}
let changes: Vec<Change> = merge_changes(place, root, intent)?;
let mut standings: Vec<Standing> = Vec::with_capacity(changes.len());
for change in &changes {
let state: WorktreeState = worktree_state(&place.git, root, change.path.as_slice()).map_err(RecoveryError)?;
standings.push(standing(change, &state));
}
let index_started: bool = index_holds_the_merge(place, root, intent.old_oid.as_str(), changes.as_slice())?;
let worktree_started: bool = standings.contains(&Standing::New);
if !index_started && !worktree_started {
remove_record(record)?;
return Ok(MergeRecoveryAction::Discarded);
}
let foreign: Vec<String> = changes
.iter()
.zip(standings.iter())
.filter(|(_, found)| { return **found == Standing::Foreign; })
.map(|(change, _)| { return String::from_utf8_lossy(change.path.as_slice()).into_owned(); })
.collect();
if !foreign.is_empty() {
return Err(RecoveryError(format!(
"A merge in {} was interrupted while Git updated these files, and they now hold neither their old nor \
their merged content: {}. Nothing was changed and the record {} was kept. Save your edits to them \
elsewhere, restore them with `git restore -- <path>`, then run any git command again so recovery can \
finish the merge; or remove the record to abandon the merge and check `git status` yourself.",
root.display(),
foreign.join(", "),
record.display()
)));
}
complete(place, root, intent, &changes, &standings)?;
remove_record(record)?;
return Ok(MergeRecoveryAction::Completed);
}
pub(crate) fn remove_record(record: &Path) -> Result<(), RecoveryError> {
return remove_merge_intent(record).map_err(|error| { return io_failure("removing", record, &error); });
}
fn complete(
place: &RecoveryPlace,
root: &Path,
intent: &MergeIntent,
changes: &[Change],
standings: &[Standing],
) -> Result<(), RecoveryError> {
install_entries(place, root, changes, standings)?;
let message: String = String::from("merge: Merge made by the 'ort' strategy. (completed by cli-git recovery)");
let mut arguments: Vec<&str> = vec!["update-ref", "-m", message.as_str()];
if intent.target_ref.is_none() {
arguments.push("--no-deref");
}
arguments.push(intent.target_ref.as_deref().unwrap_or("HEAD"));
arguments.push(intent.new_oid.as_str());
arguments.push(intent.old_oid.as_str());
run_git_checked(&place.git, &GitRequest::new(root, &arguments))
.map_err(|error| { return RecoveryError(error.to_string()); })?;
return Ok(());
}
pub(crate) fn install_entries(
place: &RecoveryPlace,
root: &Path,
changes: &[Change],
standings: &[Standing],
) -> Result<(), RecoveryError> {
let mut index_input: Vec<u8> = Vec::new();
for change in changes {
if change.exists_after() {
index_input.extend_from_slice(format!("{} {}\t", change.new_mode, change.new_oid).as_bytes());
} else {
index_input.extend_from_slice(format!("0 {}\t", "0".repeat(change.old_oid.len())).as_bytes());
}
index_input.extend_from_slice(change.path.as_slice());
index_input.push(0);
}
let mut update: GitRequest = GitRequest::new(root, &["update-index", "-z", "--index-info"]);
update.input = Some(index_input);
run_git_checked(&place.git, &update).map_err(|error| { return RecoveryError(error.to_string()); })?;
let mut checkout_input: Vec<u8> = Vec::new();
for (change, found) in changes.iter().zip(standings.iter()) {
let location: PathBuf = worktree_path(root, change.path.as_slice());
if !change.exists_after() {
remove_file_and_empty_parents(root, location.as_path())?;
} else if *found != Standing::New {
checkout_input.extend_from_slice(change.path.as_slice());
checkout_input.push(0);
}
}
if !checkout_input.is_empty() {
let mut checkout: GitRequest = GitRequest::new(root, &["checkout-index", "--force", "-z", "--stdin"]);
checkout.input = Some(checkout_input);
let checked_out = run_git_checked(&place.git, &checkout).map_err(|error| { return RecoveryError(error.to_string()); })?;
record_words(&["checkout-index", "--force", "-z", "--stdin"], &checked_out);
}
return Ok(());
}
pub(crate) fn remove_file_and_empty_parents(root: &Path, file: &Path) -> Result<(), RecoveryError> {
match std::fs::remove_file(file) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(io_failure("removing", file, &error)),
}
let mut directory: Option<&Path> = file.parent();
while let Some(current) = directory {
if current == root || !current.starts_with(root) || std::fs::remove_dir(current).is_err() {
break;
}
directory = current.parent();
}
return Ok(());
}
#[cfg(test)]
#[path = "merge_isolation_recovery_tests.rs"]
mod tests;