use crate::client_journal::record_entry_as;
use crate::diagnostic_log::debug;
use crate::journald_native::Priority;
use crate::path_bytes::path_message_text;
use crate::word_merge::{LineSpan, MergedPart, WordMerge, merge_by_word};
pub use crate::merge_second_pass_conflicts::{Conflict, Stage, conflicts_of_merge_tree};
use crate::merge_second_pass_conflicts::{is_nul_byte, split_once};
pub use crate::merge_second_pass_git_runner::{ContextRunner, GitReply, GitRunner, config_options};
use std::collections::BTreeMap;
use std::path::Path;
const TAG: &str = "wordMerge";
pub const REPORT_MARK: &str = "word-level merge";
pub const FILE_FIELD: &str = "CLI_GIT_WORD_MERGE_FILE";
pub const OPERATION_FIELD: &str = "CLI_GIT_WORD_MERGE_OPERATION";
pub const PARTS_FIELD: &str = "CLI_GIT_WORD_MERGE_PARTS";
const BINARY_PROBE_BYTES: usize = 8000;
const GIT_MAX_XDIFF_SIZE: usize = 1024 * 1024 * 1023;
const PASS_COST_LIMIT_BYTES: usize = 8 * 1024 * 1024;
const LARGEST_BLOB_BYTES: usize = if PASS_COST_LIMIT_BYTES < GIT_MAX_XDIFF_SIZE { PASS_COST_LIMIT_BYTES } else { GIT_MAX_XDIFF_SIZE };
const EXCERPT_CHARACTERS: usize = 100;
const PLAIN_FILE_MODES: [u32; 2] = [0o100_644, 0o100_755];
const TEXT_MERGE_VALUES: [&str; 3] = ["unspecified", "set", "text"];
const ATTRIBUTE_FIELDS: usize = 3;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Left {
MissingStage,
NotAPlainFile,
Binary,
TooLarge,
Driver(String),
Overlap(usize),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Merged {
pub path: Vec<u8>,
pub mode: u32,
pub bytes: Vec<u8>,
pub parts: Vec<MergedPart>,
pub stages: Conflict,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Verdict {
Merged(Box<Merged>),
Left {
path: Vec<u8>,
why: Left,
},
}
fn unfit(bytes: &[u8]) -> Option<Left> {
if bytes.len() > LARGEST_BLOB_BYTES {
return Some(Left::TooLarge);
}
if bytes[..bytes.len().min(BINARY_PROBE_BYTES)].contains(&0) {
return Some(Left::Binary);
}
return None;
}
fn shared_plain_mode(stages: [&Stage; 3]) -> Option<u32> {
let mode: u32 = stages[0].mode;
for stage in stages {
if stage.mode != mode {
return None;
}
}
if PLAIN_FILE_MODES.contains(&mode) {
return Some(mode);
}
return None;
}
fn merge_attributes(runner: &dyn GitRunner, paths: &[&[u8]]) -> Result<BTreeMap<Vec<u8>, String>, String> {
if paths.is_empty() {
return Ok(BTreeMap::new());
}
let mut input: Vec<u8> = Vec::new();
for path in paths {
input.extend_from_slice(path);
input.push(0);
}
let reply: GitReply = runner.run(&["check-attr", "-z", "--stdin", "merge"], Some(input.as_slice()), None)?;
if reply.code != Some(0) {
return Err(format!("git check-attr failed: {}", String::from_utf8_lossy(&reply.stderr).trim()));
}
let fields: Vec<&[u8]> = reply.stdout.split(is_nul_byte).collect();
let mut values: BTreeMap<Vec<u8>, String> = BTreeMap::new();
for triple in fields.as_chunks::<ATTRIBUTE_FIELDS>().0 {
values.insert(triple[0].to_vec(), String::from_utf8_lossy(triple[2]).into_owned());
}
return Ok(values);
}
fn read_blobs(runner: &dyn GitRunner, oids: &[&str]) -> Result<BTreeMap<String, Vec<u8>>, String> {
let mut input: Vec<u8> = Vec::new();
for oid in oids {
input.extend_from_slice(oid.as_bytes());
input.push(b'\n');
}
let reply: GitReply = runner.run(&["cat-file", "--batch"], Some(input.as_slice()), None)?;
if reply.code != Some(0) {
return Err(format!("git cat-file failed: {}", String::from_utf8_lossy(&reply.stderr).trim()));
}
let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();
let mut rest: &[u8] = reply.stdout.as_slice();
for oid in oids {
let Some((header_bytes, after)) = split_once(rest, b'\n') else { return Err(format!("git cat-file ended before {oid}")) };
let header: String = String::from_utf8_lossy(header_bytes).into_owned();
let words: Vec<&str> = header.split(' ').collect();
let [name, _kind, size_text] = words.as_slice() else { return Err(format!("git cat-file could not read {oid}: {header}")) };
let Ok(size) = size_text.parse::<usize>() else {
return Err(format!("git cat-file printed an unreadable size for {oid}: {header}"));
};
let Some(content) = after.get(..size) else { return Err(format!("git cat-file ended inside {oid}")) };
blobs.insert((*name).to_owned(), content.to_vec());
rest = after.get(size + 1..).unwrap_or_default();
}
return Ok(blobs);
}
pub fn second_pass(runner: &dyn GitRunner, conflicts: &[Conflict]) -> Result<Vec<Verdict>, String> {
let mut verdicts: BTreeMap<usize, Verdict> = BTreeMap::new();
let mut candidates: Vec<(usize, u32)> = Vec::new();
for (index, conflict) in conflicts.iter().enumerate() {
let (Some(base), Some(ours), Some(theirs)) = (&conflict.base, &conflict.ours, &conflict.theirs) else {
verdicts.insert(index, Verdict::Left { path: conflict.path.clone(), why: Left::MissingStage });
continue;
};
match shared_plain_mode([base, ours, theirs]) {
Some(mode) => candidates.push((index, mode)),
None => {
verdicts.insert(index, Verdict::Left { path: conflict.path.clone(), why: Left::NotAPlainFile });
}
}
}
let mut candidate_paths: Vec<&[u8]> = Vec::new();
for (index, _) in &candidates {
candidate_paths.push(conflicts[*index].path.as_slice());
}
let attributes: BTreeMap<Vec<u8>, String> = merge_attributes(runner, candidate_paths.as_slice())?;
let mut wanted: Vec<&str> = Vec::new();
for (index, _) in &candidates {
let conflict: &Conflict = &conflicts[*index];
for stage in [&conflict.base, &conflict.ours, &conflict.theirs].into_iter().flatten() {
if !wanted.contains(&stage.oid.as_str()) {
wanted.push(stage.oid.as_str());
}
}
}
let blobs: BTreeMap<String, Vec<u8>> = if wanted.is_empty() { BTreeMap::new() } else { read_blobs(runner, wanted.as_slice())? };
for (index, mode) in candidates {
let conflict: &Conflict = &conflicts[index];
if let Some(value) = attributes.get(&conflict.path)
&& !TEXT_MERGE_VALUES.contains(&value.as_str())
{
verdicts.insert(index, Verdict::Left { path: conflict.path.clone(), why: Left::Driver(value.clone()) });
continue;
}
let mut sides: Vec<&Vec<u8>> = Vec::new();
for stage in [&conflict.base, &conflict.ours, &conflict.theirs] {
if let Some(found) = stage.as_ref()
&& let Some(bytes) = blobs.get(&found.oid)
{
sides.push(bytes);
}
}
let [base, ours, theirs] = sides.as_slice() else { return Err(format!("a stage of {} was not read", path_message_text(conflict.path.as_slice()))) };
let mut reason: Option<Left> = None;
for bytes in [base, ours, theirs] {
reason = unfit(bytes);
if reason.is_some() {
break;
}
}
let verdict: Verdict = match reason {
Some(why) => Verdict::Left { path: conflict.path.clone(), why },
None => match merge_by_word(base, ours, theirs) {
WordMerge::Resolved { merged, parts } => Verdict::Merged(Box::new(Merged { path: conflict.path.clone(), mode, bytes: merged, parts, stages: conflict.clone() })),
WordMerge::Conflict { unresolved } => Verdict::Left { path: conflict.path.clone(), why: Left::Overlap(unresolved) },
},
};
verdicts.insert(index, verdict);
}
return Ok(verdicts.into_values().collect());
}
pub fn write_blob(runner: &dyn GitRunner, bytes: &[u8]) -> Result<String, String> {
let reply: GitReply = runner.run(&["hash-object", "-w", "--no-filters", "--stdin"], Some(bytes), None)?;
if reply.code != Some(0) {
return Err(format!("git hash-object failed: {}", String::from_utf8_lossy(&reply.stderr).trim()));
}
return Ok(String::from_utf8_lossy(&reply.stdout).trim().to_owned());
}
pub fn resolved_record(mode: u32, oid: &str, path: &[u8]) -> Vec<u8> {
let mut record: Vec<u8> = format!("{mode:06o} {oid}\t").into_bytes();
record.extend_from_slice(path);
record.push(0);
return record;
}
pub fn tree_with_files(runner: &dyn GitRunner, tree: &str, files: &[(&[u8], u32, &str)], scratch_index: &Path) -> Result<String, String> {
let read: GitReply = runner.run(&["read-tree", tree], None, Some(scratch_index))?;
if read.code != Some(0) {
return Err(format!("git read-tree failed: {}", String::from_utf8_lossy(&read.stderr).trim()));
}
let mut records: Vec<u8> = Vec::new();
for (path, mode, oid) in files {
records.extend_from_slice(resolved_record(*mode, oid, path).as_slice());
}
let updated: GitReply = runner.run(&["update-index", "-z", "--index-info"], Some(records.as_slice()), Some(scratch_index))?;
if updated.code != Some(0) {
return Err(format!("git update-index failed: {}", String::from_utf8_lossy(&updated.stderr).trim()));
}
let written: GitReply = runner.run(&["write-tree"], None, Some(scratch_index))?;
if written.code != Some(0) {
return Err(format!("git write-tree failed: {}", String::from_utf8_lossy(&written.stderr).trim()));
}
return Ok(String::from_utf8_lossy(&written.stdout).trim().to_owned());
}
fn span_text(span: &LineSpan) -> String {
return match span.count {
0 => format!("no line (before line {})", span.first),
1 => format!("line {}", span.first),
count => format!("lines {} to {}", span.first, span.first + count - 1),
};
}
fn excerpt(merged: &[u8]) -> String {
let escaped: String = String::from_utf8_lossy(merged).escape_debug().to_string();
return match escaped.char_indices().nth(EXCERPT_CHARACTERS) {
Some((end, _)) => format!("\"{}...\"", &escaped[..end]),
None => format!("\"{escaped}\""),
};
}
pub fn part_text(part: &MergedPart) -> String {
return format!(
"base {}, ours {}, theirs {} became {}",
span_text(&part.base),
span_text(&part.ours),
span_text(&part.theirs),
excerpt(part.merged.as_slice())
);
}
pub fn report_lines(prefix: &str, merged: &Merged) -> Vec<String> {
let path: String = path_message_text(merged.path.as_slice());
if merged.parts.is_empty() {
return vec![format!("{prefix}{REPORT_MARK}: {path}: resolved without a word pass (every region took one side)")];
}
let mut lines: Vec<String> = Vec::new();
for (at, part) in merged.parts.iter().enumerate() {
lines.push(format!("{prefix}{REPORT_MARK}: {path}: part {} of {}: {}", at + 1, merged.parts.len(), part_text(part)));
}
return lines;
}
pub fn journal_entry(operation: &str, merged: &Merged) -> (String, Vec<(&'static str, String)>) {
let path: String = path_message_text(merged.path.as_slice());
let message: String = report_lines("", merged).join("; ");
return (message, vec![(FILE_FIELD, path), (OPERATION_FIELD, operation.to_owned()), (PARTS_FIELD, merged.parts.len().to_string())]);
}
pub fn journal_merged(identifier: &str, operation: &str, merged: &Merged) {
let (message, fields) = journal_entry(operation, merged);
let sent: bool = record_entry_as(identifier, Priority::Warning, message.as_str(), fields.as_slice());
debug(TAG, format!("journal entry for a merged file: sent={sent}").as_str());
}
#[cfg(test)]
#[path = "merge_second_pass_tests.rs"]
pub(crate) mod tests;