monochromatic-git-policy-cli 0.6.0

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! What:
//!  The word-level second pass over the files a Git merge left conflicted: which conflicted files may be re-merged, their
//!  re-merge by `word_merge`, the objects and the tree that carry the result, and the words that list every merged part
//!  on standard error and in the journal.
//! Why:
//!  The owner decided on 2026-11-03 (`doc/planning/cli-git-merge-auto-resolution.md`) that the merges a person runs and the
//!  wrapper's own unattended merges get a second pass that accepts a file only when every part resolves and shows each part.
//!  Four callers need the same answer from the same stage blobs: the forwarded merge, rebase, cherry-pick and revert
//!  (`merge_second_pass_worktree.rs`), the isolated merge, the auto-push reconcile merge and the commit replay merge, which
//!  work from objects (`merge_second_pass_tree.rs`). They differ in how Git is started, so they hand in a `GitRunner`.
//!  Only a plain file whose three stages exist, with one mode on all three, that is text, small, and not claimed by a merge
//!  driver or the `binary` attribute, is re-merged; everything else is left to the person exactly as Git left it.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const verdicts = await secondPass(runner, conflicts); // [{ merged: { path, mode, bytes, parts } } | { left: { path, why } }]
//! ```

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;

/// Tag used for diagnostics from this module.
const TAG: &str = "wordMerge";

/// The words every line about a merged part carries, so a reader finds them in a long output.
pub const REPORT_MARK: &str = "word-level merge";

/// The journal field that names the file of an entry.
pub const FILE_FIELD: &str = "CLI_GIT_WORD_MERGE_FILE";

/// The journal field that names the operation that produced the conflict.
pub const OPERATION_FIELD: &str = "CLI_GIT_WORD_MERGE_OPERATION";

/// The journal field that counts the parts of the file that were merged by word.
pub const PARTS_FIELD: &str = "CLI_GIT_WORD_MERGE_PARTS";

/// The number of leading bytes in which Git looks for a NUL byte to call a file binary: `FIRST_FEW_BYTES` in Git 2.56.0's
///  `xdiff-interface.c` line 197, used by `buffer_is_binary` and by the merge driver choice in `merge-ll.c` lines 120 to 122.
const BINARY_PROBE_BYTES: usize = 8000;

/// The size above which Git's own merge treats a file as binary and does not merge it by text: `MAX_XDIFF_SIZE`, 1023 MiB, in
///  `xdiff-interface.h` line 14, tested in `merge-ll.c` lines 117 to 119.
const GIT_MAX_XDIFF_SIZE: usize = 1024 * 1024 * 1023;

/// The size above which this pass leaves a file to the person, although Git would still merge it by text. Git's value cannot
///  bound the cost of this pass: measured on three texts that share no line, one 1 MiB of lines took 0.08 s and 45 MB, 8 MiB took
///  0.73 s and 352 MB, 32 MiB took 4.12 s and 1320 MB (release build, Myers diff, one core), so a file at Git's limit would take
///  minutes and tens of gigabytes. 8 MiB keeps the worst case under a second.
const PASS_COST_LIMIT_BYTES: usize = 8 * 1024 * 1024;

/// The largest blob the second pass re-merges: the smaller of Git's own limit and the one measured above.
const LARGEST_BLOB_BYTES: usize = if PASS_COST_LIMIT_BYTES < GIT_MAX_XDIFF_SIZE { PASS_COST_LIMIT_BYTES } else { GIT_MAX_XDIFF_SIZE };

/// The most characters of a merged part a report line shows.
const EXCERPT_CHARACTERS: usize = 100;

/// The modes of a plain file, with and without the executable bit.
const PLAIN_FILE_MODES: [u32; 2] = [0o100_644, 0o100_755];

/// The values of the `merge` attribute that leave the text merge to Git's own algorithm: none given, `merge` set, `text`.
const TEXT_MERGE_VALUES: [&str; 3] = ["unspecified", "set", "text"];

/// The fields `git check-attr -z` prints for each answer: the path, the attribute and its value.
const ATTRIBUTE_FIELDS: usize = 3;

/// Why a conflicted path is left to the person.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Left {
    /// A stage is missing: both sides added the path, or one side deleted it.
    MissingStage,
    /// The stages differ in mode, or the file is a link, a submodule or not a plain file.
    NotAPlainFile,
    /// A stage holds a NUL byte near its start.
    Binary,
    /// A stage is larger than the pass takes.
    TooLarge,
    /// The `merge` attribute names a driver, `binary` or is unset, so the merge belongs to that setting.
    Driver(String),
    /// At least one line region holds two different changes of the same words.
    Overlap(usize),
}

/// A conflicted file that the word pass merged completely.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Merged {
    /// The path as bytes.
    pub path: Vec<u8>,
    /// The mode all three stages share.
    pub mode: u32,
    /// The merged bytes.
    pub bytes: Vec<u8>,
    /// The line regions that needed the word pass.
    pub parts: Vec<MergedPart>,
    /// The three stages the file was merged from, kept to put the conflict back if a later step fails.
    pub stages: Conflict,
}

/// What became of one conflicted path.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Verdict {
    /// Merged completely.
    Merged(Box<Merged>),
    /// Left as Git left it.
    Left {
        /// The path as bytes.
        path: Vec<u8>,
        /// Why.
        why: Left,
    },
}

/// What:
///  Whether the bytes are text the pass may merge: no NUL byte near the start, and not larger than the limit.
/// Why:
///   Git's own binary test looks at the first bytes only; a file it would call binary is never a candidate for a
///  line-and-word merge.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const verdict = (bytes) => bytes.length > LARGEST ? 'too-large' : bytes.subarray(0, PROBE).includes(0) ? 'binary' : 'text';
/// ```
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;
}

/// The mode all three stages share, when it is a plain file's.
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;
}

/// What:
///  The value of the `merge` attribute of each path, from one `git check-attr` start.
/// Why:
///   A path with a driver, with `-merge` or with `merge=binary` must be merged the way its setting says, not by a second
///  pass that ignores the setting. The caller's runner decides where attributes come from: the worktree for a merge a person
///  runs, the attribute tree for a merge from objects.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function mergeAttributes(runner, paths: Uint8Array[]): Promise<Map<string, string>>;
/// ```
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);
}

/// What:
///  The bytes of the named blobs, from one `git cat-file --batch` start.
/// Why:
///   One start reads every stage of every candidate file; the answer for a name is its header line and then its bytes.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function readBlobs(runner, oids: string[]): Promise<Map<string, Uint8Array>>;
/// ```
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);
}

/// What:
///  Re-merge every conflicted path that may be, and say what became of each.
/// Why:
///   A path is merged only when all three stages exist with one plain mode, are small text, and the `merge` attribute leaves
///  the text merge to Git; and only when every line region resolves. Anything else is `Left`, so the caller keeps the conflict.
///   `Err` means a Git start failed; the caller then leaves every conflict as Git left it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function secondPass(runner: GitRunner, conflicts: Conflict[]): Promise<Verdict[]>;
/// ```
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());
}

/// What:
///  Write one merged file as a blob and return its object name.
/// Why:
///   `--no-filters` keeps the bytes exactly as merged; the checkout filters apply when a worktree file is made, not here.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function writeBlob(runner: GitRunner, bytes: Uint8Array): Promise<string>;
/// ```
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());
}

/// The `update-index --index-info` record that puts a blob at a path as a resolved entry.
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;
}

/// What:
///  The tree that is `tree` with the named files replaced, from a scratch index.
/// Why:
///   The tree `git merge-tree` wrote holds the conflicted file with markers; replacing it by the merged blob makes the tree
///  of the merge as if Git had merged cleanly. A scratch index is the way to edit a tree with Git's own commands.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function treeWithFiles(runner, tree: string, files: { path, mode, oid }[], scratchIndex: string): Promise<string>;
/// ```
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());
}

/// The words for a run of lines.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// span.count === 0 ? `no line (before line ${span.first})` : span.count === 1 ? `line ${span.first}` : `lines ${span.first} to ${span.first + span.count - 1}`
/// ```
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),
    };
}

/// What:
///  What a merged part became, as one escaped line of at most `EXCERPT_CHARACTERS` characters.
/// Why:
///   The reader sees the new text without opening the file; escaping keeps a terminal control sequence in a file from
///  reaching the terminal.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// JSON.stringify(text).slice(0, EXCERPT_CHARACTERS)
/// ```
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}\""),
    };
}

/// One part as a sentence fragment: where it was in the three texts and what it became.
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())
    );
}

/// What:
///  The lines that list a merged file: one per part, or one for the file when it needed no word pass.
/// Why:
///   The owner's decision is that every merged part is visible by file and by part.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function reportLines(prefix: string, merged: Merged): string[];
/// ```
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;
}

/// What:
///  The message and the fields of the journal entry for a merged file.
/// Why:
///   One entry names the file, the operation and every part, so `journalctl` finds a merge by file.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// journalEntry('merge', merged) // { message, fields }
/// ```
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())]);
}

/// What:
///  Send one journal entry for a merged file under the identifier of the program that merged it.
/// Why:
///   The wrapper's standard error is a terminal or a capture that is gone when the command ends; the journal keeps the
///  file and each part for later. Code that runs inside the service sends under the service's identifier.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// recordEntryAs(identifier, 'warning', `word-level merge: ${path}: ${parts.join('; ')}`, { CLI_GIT_WORD_MERGE_FILE: path, ... });
/// ```
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());
}

/// Controls for the parsing, the eligibility rules, the objects and the report words.
#[cfg(test)]
#[path = "merge_second_pass_tests.rs"]
pub(crate) mod tests;