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::transaction_git::{GitContext, GitRequest, run_git};
use crate::word_merge::{LineSpan, MergedPart, WordMerge, merge_by_word};
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
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;
const BASE_STAGE: u8 = 1;
const OURS_STAGE: u8 = 2;
const THEIRS_STAGE: u8 = 3;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitReply {
pub code: Option<i32>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
pub trait GitRunner {
fn run(&self, arguments: &[&str], input: Option<&[u8]>, index_file: Option<&Path>) -> Result<GitReply, String>;
}
pub struct ContextRunner<'a> {
pub context: &'a GitContext,
pub cwd: &'a Path,
pub without_prefix: bool,
pub object_directory: Option<&'a Path>,
pub environment: Vec<(OsString, OsString)>,
}
impl GitRunner for ContextRunner<'_> {
fn run(&self, arguments: &[&str], input: Option<&[u8]>, index_file: Option<&Path>) -> Result<GitReply, String> {
let mut request: GitRequest = GitRequest::new(self.cwd, arguments);
request.without_prefix = self.without_prefix;
request.object_directory = self.object_directory.map(Path::to_path_buf);
request.environment.clone_from(&self.environment);
request.input = input.map(<[u8]>::to_vec);
request.index = index_file.map(PathBuf::from);
let output = run_git(self.context, &request).map_err(|error| { return format!("git {} could not start: {error}", arguments.join(" ")); })?;
return Ok(GitReply { code: output.code, stdout: output.stdout, stderr: output.stderr });
}
}
pub fn config_options(prefix: &[OsString]) -> Vec<OsString> {
let mut kept: Vec<OsString> = Vec::new();
let mut tokens = prefix.iter();
while let Some(token) = tokens.next() {
if token == "-c"
&& let Some(setting) = tokens.next()
{
kept.push(token.clone());
kept.push(setting.clone());
}
}
return kept;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Stage {
pub mode: u32,
pub oid: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Conflict {
pub path: Vec<u8>,
pub base: Option<Stage>,
pub ours: Option<Stage>,
pub theirs: Option<Stage>,
}
pub fn conflicts_of_merge_tree(stdout: &[u8]) -> (String, Vec<Conflict>) {
let mut fields = stdout.split(|byte| { return *byte == 0; });
let tree: String = String::from_utf8_lossy(fields.next().unwrap_or_default()).into_owned();
let mut by_path: BTreeMap<Vec<u8>, Conflict> = BTreeMap::new();
let mut order: Vec<Vec<u8>> = Vec::new();
for field in fields {
if field.is_empty() {
break;
}
let Some((facts, path)) = split_once(field, b'\t') else { continue };
let words: Vec<&[u8]> = facts.split(|byte| { return *byte == b' '; }).collect();
let [mode_bytes, oid_bytes, stage_bytes] = words.as_slice() else { continue };
let (Ok(mode_text), Ok(oid_text), Ok(stage_text)) = (std::str::from_utf8(mode_bytes), std::str::from_utf8(oid_bytes), std::str::from_utf8(stage_bytes)) else { continue };
let (Ok(mode), Ok(stage)) = (u32::from_str_radix(mode_text, 8), stage_text.parse::<u8>()) else { continue };
if !by_path.contains_key(path) {
order.push(path.to_vec());
}
let conflict: &mut Conflict = by_path.entry(path.to_vec()).or_insert_with(|| { return Conflict { path: path.to_vec(), base: None, ours: None, theirs: None }; });
let side: Option<Stage> = Some(Stage { mode, oid: oid_text.to_owned() });
match stage {
BASE_STAGE => conflict.base = side,
OURS_STAGE => conflict.ours = side,
THEIRS_STAGE => conflict.theirs = side,
_ => {}
}
}
return (tree, order.into_iter().filter_map(|path| { return by_path.remove(&path); }).collect());
}
fn split_once(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> {
let at: usize = bytes.iter().position(|byte| { return *byte == separator; })?;
return Some((&bytes[..at], &bytes[at + 1..]));
}
#[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;
if stages.iter().all(|stage| { return stage.mode == mode; }) && 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(|byte| { return *byte == 0; }).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 size: usize = size_text.parse().map_err(|_| { return 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 attributes: BTreeMap<Vec<u8>, String> = merge_attributes(
runner,
candidates.iter().map(|(index, _)| { return conflicts[*index].path.as_slice(); }).collect::<Vec<&[u8]>>().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).filter(|value| { return !TEXT_MERGE_VALUES.contains(&value.as_str()); }) {
verdicts.insert(index, Verdict::Left { path: conflict.path.clone(), why: Left::Driver(value.clone()) });
continue;
}
let sides: Vec<&Vec<u8>> = [&conflict.base, &conflict.ours, &conflict.theirs]
.into_iter()
.filter_map(|stage| { return stage.as_ref().and_then(|found| { return blobs.get(&found.oid); }); })
.collect();
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 verdict: Verdict = match [base, ours, theirs].into_iter().find_map(|bytes| { return unfit(bytes); }) {
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)")];
}
return merged
.parts
.iter()
.enumerate()
.map(|(at, part)| { return format!("{prefix}{REPORT_MARK}: {path}: part {} of {}: {}", at + 1, merged.parts.len(), part_text(part)); })
.collect();
}
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;