use crate::child_environment::environment_value;
use crate::conflict_side_state::conflicted_paths;
use crate::diagnostic_log::{debug, warn};
use crate::index_writer_forward::CoordinatedForward;
use crate::merge_second_pass::{Conflict, ContextRunner, GitReply, GitRunner, Left, Merged, Stage, Verdict, config_options, resolved_record, second_pass, write_blob};
use crate::merge_second_pass_finish::{finish, plan_finish, split_cmdline};
use crate::journald_native::CLIENT_IDENTIFIER;
use crate::merge_second_pass_tree::announce;
use crate::path_bytes::path_message_text;
use crate::private_storage::read_regular_file;
use crate::repo_read_index::{IndexEntry, parse_index};
use crate::repo_read_layout::{RepoLayout, read_layout_after_success_for};
use crate::repo_read_object::to_hex;
use crate::transaction_git::GitContext;
use std::collections::BTreeSet;
use std::io::Write;
const TAG: &str = "wordMergeWorktree";
const INDEX_LIMIT_BYTES: u64 = 1 << 30;
const LINE_PREFIX: &str = "cli-git: ";
const OPERATIONS: [&[u8]; 5] = [b"merge", b"rebase", b"cherry-pick", b"revert", b"pull"];
const STAGE_NUMBERS: [u8; 3] = [1, 2, 3];
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Watch {
unmerged_before: BTreeSet<Vec<u8>>,
}
fn layout_of(forward: &CoordinatedForward) -> Option<RepoLayout> {
if environment_value(forward.environment.as_slice(), "GIT_INDEX_FILE").is_some() {
return None;
}
return read_layout_after_success_for(&forward.command.arguments[..forward.command.subcommand_index], forward.overlay.as_slice());
}
fn index_entries(layout: &RepoLayout) -> Option<Vec<IndexEntry>> {
let bytes: Vec<u8> = read_regular_file(layout.git_dir.join("index").as_path(), INDEX_LIMIT_BYTES).ok()?;
return parse_index(bytes.as_slice(), layout.object_format);
}
pub fn conflicts_of_index(entries: &[IndexEntry], only: &BTreeSet<Vec<u8>>) -> Vec<Conflict> {
let mut conflicts: Vec<Conflict> = Vec::new();
for entry in entries.iter().filter(|entry| { return entry.stage != 0 && only.contains(&entry.path); }) {
if conflicts.last().is_none_or(|last| { return last.path != entry.path; }) {
conflicts.push(Conflict { path: entry.path.clone(), base: None, ours: None, theirs: None });
}
let Some(conflict) = conflicts.last_mut() else { continue };
let side: Option<Stage> = Some(Stage { mode: entry.mode, oid: to_hex(entry.id.as_slice()) });
match entry.stage {
1 => conflict.base = side,
2 => conflict.ours = side,
3 => conflict.theirs = side,
_ => {}
}
}
return conflicts;
}
pub fn watch_before(forward: &CoordinatedForward) -> Option<Watch> {
if !forward.command.is_builtin() || !OPERATIONS.contains(&forward.command.word()) {
return None;
}
let layout: RepoLayout = layout_of(forward)?;
let entries: Vec<IndexEntry> = index_entries(&layout)?;
return Some(Watch { unmerged_before: conflicted_paths(entries.as_slice()) });
}
fn restore_records(conflict: &Conflict) -> Vec<u8> {
let width: usize = [&conflict.base, &conflict.ours, &conflict.theirs].into_iter().flatten().map(|stage| { return stage.oid.len(); }).next().unwrap_or(0);
let mut records: Vec<u8> = format!("0 {}\t", "0".repeat(width)).into_bytes();
records.extend_from_slice(conflict.path.as_slice());
records.push(0);
for (number, side) in STAGE_NUMBERS.iter().zip([&conflict.base, &conflict.ours, &conflict.theirs]) {
if let Some(stage) = side {
records.extend_from_slice(format!("{:06o} {} {number}\t", stage.mode, stage.oid).as_bytes());
records.extend_from_slice(conflict.path.as_slice());
records.push(0);
}
}
return records;
}
fn succeeded(started: Result<GitReply, String>, what: &str) -> Result<(), String> {
let reply: GitReply = started?;
if reply.code == Some(0) {
return Ok(());
}
return Err(format!("{what} failed: {}", String::from_utf8_lossy(&reply.stderr).trim()));
}
fn install(runner: &dyn GitRunner, merged: &[Merged], oids: &[String]) -> Result<(), String> {
let mut staging: Vec<u8> = Vec::new();
let mut paths: Vec<u8> = Vec::new();
for (file, oid) in merged.iter().zip(oids.iter()) {
staging.extend_from_slice(resolved_record(file.mode, oid.as_str(), file.path.as_slice()).as_slice());
paths.extend_from_slice(file.path.as_slice());
paths.push(0);
}
succeeded(runner.run(&["update-index", "-z", "--index-info"], Some(staging.as_slice()), None), "git update-index")?;
let written: Result<(), String> = succeeded(runner.run(&["checkout-index", "--force", "-z", "--stdin"], Some(paths.as_slice()), None), "git checkout-index");
if let Err(reason) = written {
let mut restore: Vec<u8> = Vec::new();
for file in merged {
restore.extend_from_slice(restore_records(&file.stages).as_slice());
}
if let Err(again) = succeeded(runner.run(&["update-index", "-z", "--index-info"], Some(restore.as_slice()), None), "git update-index") {
return Err(format!("{reason}; putting the conflicts back also failed: {again}"));
}
return Err(reason);
}
return Ok(());
}
fn closing_words(operation: &str, remaining: usize) -> String {
let rest: String = if remaining == 0 {
format!("no conflicted file is left; finish the {operation} the way Git said above")
} else {
format!("resolve the files Git still lists as conflicted, then finish the {operation} the way Git said above")
};
return format!("Review a merged file with `git diff --cached -- <file>`; {rest}.");
}
pub fn resolve_after(watched: Option<&Watch>, forward: &CoordinatedForward, code: i32, stderr: &mut dyn Write) -> Option<i32> {
let watch = watched?;
if code == 0 {
return None;
}
let Some(layout) = layout_of(forward) else {
debug(TAG, "the layout cannot be read in process after the command; no second pass");
return None;
};
let Some(entries) = index_entries(&layout) else {
debug(TAG, "the index cannot be read in process after the command; no second pass");
return None;
};
let created: BTreeSet<Vec<u8>> = conflicted_paths(entries.as_slice()).difference(&watch.unmerged_before).cloned().collect();
if created.is_empty() {
return None;
}
let conflicts: Vec<Conflict> = conflicts_of_index(entries.as_slice(), &created);
let context: GitContext = GitContext {
real_git: forward.real_git.clone(),
overlay: forward.overlay.clone(),
global_prefix: config_options(&forward.command.arguments[..forward.command.subcommand_index]),
common_dir: Some(layout.common_dir.clone()),
};
let runner: ContextRunner<'_> = ContextRunner { context: &context, cwd: layout.worktree_root.as_path(), without_prefix: false, object_directory: None, environment: Vec::new() };
let verdicts: Vec<Verdict> = match second_pass(&runner, conflicts.as_slice()) {
Ok(found) => found,
Err(reason) => {
warn(TAG, format!("the second merge pass could not run, so the conflicts stay as Git left them: {reason}").as_str());
return None;
}
};
let mut merged: Vec<Merged> = Vec::new();
for verdict in verdicts {
match verdict {
Verdict::Merged(file) => merged.push(*file),
Verdict::Left { path, why } => debug(TAG, format!("{} stays conflicted: {}", path_message_text(path.as_slice()), left_words(&why)).as_str()),
}
}
if merged.is_empty() {
return None;
}
let mut oids: Vec<String> = Vec::new();
for file in &merged {
match write_blob(&runner, file.bytes.as_slice()) {
Ok(oid) => oids.push(oid),
Err(reason) => {
warn(TAG, format!("the merged blob of {} could not be written, so the conflicts stay: {reason}", path_message_text(file.path.as_slice())).as_str());
return None;
}
}
}
if let Err(reason) = install(&runner, merged.as_slice(), oids.as_slice()) {
warn(TAG, format!("the merged files could not be installed, so the conflicts stay as Git left them: {reason}").as_str());
return None;
}
let operation: String = String::from_utf8_lossy(forward.command.word()).into_owned();
let mut text: String = String::new();
for line in announce(LINE_PREFIX, (CLIENT_IDENTIFIER, operation.as_str()), merged.as_slice()) {
text.push_str(line.as_str());
text.push('\n');
}
let remaining: usize = conflicted_paths(entries.as_slice()).len().saturating_sub(merged.len());
let planned = if remaining == 0 {
branch_merge_options(&runner, layout.git_dir.as_path(), &forward.command)
.and_then(|configured| { return plan_finish(&forward.command, forward.environment.as_slice(), layout.git_dir.as_path(), configured.as_slice()); })
} else {
None
};
if planned.is_none() {
let names: Vec<String> = merged.iter().map(|file| { return path_message_text(file.path.as_slice()); }).collect();
text.push_str(
format!(
"{LINE_PREFIX}{}: staged as resolved by the second merge pass: {}. {}\n",
crate::merge_second_pass::REPORT_MARK,
names.join(", "),
closing_words(operation.as_str(), remaining)
)
.as_str(),
);
}
if let Err(error) = stderr.write_all(text.as_bytes()) {
debug(TAG, format!("standard error did not take the list of merged files: {error}").as_str());
}
let plan = planned?;
return Some(finish(&plan, &forward.command, forward.environment.as_slice(), stderr));
}
fn branch_merge_options(runner: &ContextRunner<'_>, git_dir: &std::path::Path, command: &crate::forwarded_command::ResolvedCommand) -> Option<Vec<std::ffi::OsString>> {
if !matches!(command.word(), b"merge" | b"pull") {
return Some(Vec::new());
}
let head: String = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
let Some(branch) = head.trim().strip_prefix("ref: refs/heads/") else { return Some(Vec::new()) };
let key: String = format!("branch.{branch}.mergeoptions");
return match runner.run(&["config", "--get", key.as_str()], None, None) {
Ok(reply) if reply.code == Some(1) => Some(Vec::new()),
Ok(reply) if reply.code == Some(0) => split_cmdline(String::from_utf8_lossy(&reply.stdout).trim_end_matches('\n')),
Ok(_) => None,
Err(reason) => {
debug(TAG, format!("{key} could not be read ({reason}), so the finish is not made").as_str());
None
}
};
}
fn left_words(why: &Left) -> String {
return format!("{why:?}");
}
#[cfg(test)]
#[path = "merge_second_pass_worktree_tests.rs"]
mod tests;