Skip to main content

grit_lib/diff/
patch_log.rs

1//! Git-compatible `log -p` / `show` patch output streamed to a [`Write`](std::io::Write).
2
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::io::Write;
6use std::sync::Arc;
7
8use super::structured::{StructuredFileDiff, StructuredLine, StructuredLineKind};
9use crate::attributes::load_gitattributes_for_check_attr_source;
10use crate::commit_pretty::{
11    abbrev_hex, approximate_object_count, auto_abbrev_len_for_object_count,
12};
13use crate::config::ConfigSet;
14use crate::diff::options::DiffOptions;
15use crate::error::{Error, Result};
16use crate::index::Index;
17use crate::merge_file;
18use crate::objects::pack_store::PackReadContextGuard;
19use crate::objects::CommitData;
20use crate::objects::{parse_commit, ObjectId, ObjectKind};
21use crate::odb::Odb;
22use crate::oid_abbrev_cache::OidAbbrevCache;
23use crate::parallel::{run_ordered_batches, ParallelOp};
24use crate::quote_path::format_diff_path_with_prefix;
25use crate::repo::Repository;
26use crate::userdiff::{matcher_for_path_parsed, FuncnameMatcher};
27use crate::worktree_rules::WorktreeRules;
28
29use super::merge_commit::commit_first_parent_diff_entries;
30use super::merge_commit::DiffMerges;
31use super::{empty_blob_oid, read_worktree_side_bytes_for_diff, zero_oid, DiffEntry, DiffStatus};
32
33/// How much commit metadata precedes each patch in [`write_commit_log_patch`].
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum LogPatchCommitHeader {
36    /// `git log -p` default (commit line, author, date, indented message, then patch).
37    #[default]
38    Full,
39    /// `git log -p --format=%H` (full oid line only, then patch).
40    FormatPercentH,
41}
42
43/// Binary file encoding inside a log/show patch (`git log -p` vs `git diff --binary`).
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum LogPatchBinaryFormat {
46    /// `Binary files a/... and b/... differ` (default for `git log -p`).
47    #[default]
48    LogShortMessage,
49    /// `GIT binary patch` sections (`git diff --binary`, accepted by `git apply`).
50    GitApplyBinaryPatch,
51}
52
53/// Options for [`write_commit_log_patch`].
54#[derive(Debug, Clone)]
55pub struct LogPatchOptions {
56    /// Unified diff hunk settings.
57    pub diff: DiffOptions,
58    /// Minimum abbreviated oid length (`core.abbrev`, default 7).
59    pub abbrev_len: usize,
60    /// Commit header shape before patches.
61    pub commit_header: LogPatchCommitHeader,
62    /// How to encode binary file changes in the patch body.
63    pub binary_format: LogPatchBinaryFormat,
64    /// Limit patches to these pathspecs (empty = all paths).
65    pub pathspecs: Vec<String>,
66    /// How to diff merge commits (`combined` omits apply-ready patch bodies).
67    pub diff_merges: DiffMerges,
68}
69
70impl LogPatchOptions {
71    /// Defaults matching `git log -p` on a typical repository.
72    #[must_use]
73    pub fn git_default(config: &ConfigSet) -> Self {
74        Self {
75            diff: DiffOptions::from_config(config),
76            abbrev_len: 7,
77            commit_header: LogPatchCommitHeader::Full,
78            binary_format: LogPatchBinaryFormat::default(),
79            pathspecs: Vec::new(),
80            diff_merges: DiffMerges::Combined,
81        }
82    }
83}
84
85/// Options for [`write_patch`] (`git diff --binary`-compatible output for `git apply`).
86#[derive(Debug, Clone)]
87pub struct PatchTextOptions {
88    /// Unified diff hunk settings (algorithm, context, whitespace, renames).
89    pub diff: DiffOptions,
90    /// Minimum abbreviated oid length on `index` lines for text files (`core.abbrev`).
91    pub abbrev_len: usize,
92}
93
94impl PatchTextOptions {
95    /// Resolve `core.abbrev` and attach line-diff options.
96    ///
97    /// # Errors
98    ///
99    /// Propagates config and ODB errors from abbrev resolution.
100    pub fn from_repo(repo: &Repository, diff: DiffOptions) -> Result<Self> {
101        let config = repo.config()?.clone();
102        Ok(Self {
103            diff,
104            abbrev_len: resolve_core_abbrev_len(&config, repo.odb())?,
105        })
106    }
107}
108
109/// Write `git diff --binary`-style patches for tree diff entries (for `git apply`).
110///
111/// Emits `diff --git` headers, `index` lines, unified hunks or `GIT binary patch` bodies.
112///
113/// # Errors
114///
115/// Propagates ODB reads, diff generation, and I/O errors.
116pub fn write_patch<W: Write>(
117    repo: &Repository,
118    entries: &[DiffEntry],
119    opts: &PatchTextOptions,
120    out: &mut W,
121) -> Result<()> {
122    if entries.is_empty() {
123        return Ok(());
124    }
125    let odb = repo.odb();
126    let config = repo.config()?.clone();
127    let thread_policy = repo.thread_policy();
128    let log_opts = LogPatchOptions {
129        diff: opts.diff.clone(),
130        abbrev_len: opts.abbrev_len,
131        commit_header: LogPatchCommitHeader::FormatPercentH,
132        binary_format: LogPatchBinaryFormat::GitApplyBinaryPatch,
133        pathspecs: Vec::new(),
134        diff_merges: DiffMerges::FirstParent,
135    };
136    let abbrev_len = opts.abbrev_len;
137    let mut abbrev_cache = OidAbbrevCache::new(abbrev_len);
138    let oids = entries.iter().flat_map(|e| [e.old_oid, e.new_oid]);
139    abbrev_cache.warm_for_patch_batch(repo, oids, &thread_policy, &config)?;
140    let mut par_opts = thread_policy.resolve(ParallelOp::DiffContent, &config);
141    par_opts.min_items = 0;
142    par_opts.min_total_bytes = None;
143    let n_workers = par_opts.threads.get();
144    if n_workers <= 1 || entries.len() < 2 {
145        for entry in entries {
146            write_diff_entry_patch(
147                out,
148                repo,
149                odb,
150                entry,
151                &log_opts,
152                &mut abbrev_cache,
153                None,
154                LogPatchBinaryFormat::GitApplyBinaryPatch,
155                &config,
156            )?;
157        }
158        return Ok(());
159    }
160    let store = odb.pack_store_for(odb.objects_dir());
161    let warmed_cache = abbrev_cache;
162    struct PatchWriteWorker {
163        _read: PackReadContextGuard,
164        abbrev: OidAbbrevCache,
165    }
166    run_ordered_batches(
167        entries,
168        1,
169        n_workers,
170        || PatchWriteWorker {
171            _read: PackReadContextGuard::install(Arc::clone(&store)),
172            abbrev: warmed_cache.clone(),
173        },
174        |worker, entry| {
175            let mut buf = Vec::new();
176            write_diff_entry_patch(
177                &mut buf,
178                repo,
179                odb,
180                entry,
181                &log_opts,
182                &mut worker.abbrev,
183                None,
184                LogPatchBinaryFormat::GitApplyBinaryPatch,
185                &config,
186            )?;
187            Ok(buf)
188        },
189        |_batch, chunks: Vec<Vec<u8>>| {
190            for chunk in chunks {
191                out.write_all(&chunk)?;
192            }
193            Ok(())
194        },
195    )
196}
197
198/// Write patch text from precomputed [`TreeDiffStructured`](super::tree_content::TreeDiffStructured) rows.
199///
200/// Use after [`super::tree_diff_structured_parallel`] (or `grit diff`'s commit-range path) to avoid
201/// recomputing line diffs.
202///
203/// # Errors
204///
205/// Propagates ODB reads and I/O errors.
206pub fn write_patch_structured<W: Write>(
207    repo: &Repository,
208    items: &[super::tree_content::TreeDiffStructured],
209    opts: &PatchTextOptions,
210    out: &mut W,
211) -> Result<()> {
212    if items.is_empty() {
213        return Ok(());
214    }
215    let odb = repo.odb();
216    let config = repo.config()?.clone();
217    let thread_policy = repo.thread_policy();
218    let log_opts = LogPatchOptions {
219        diff: opts.diff.clone(),
220        abbrev_len: opts.abbrev_len,
221        commit_header: LogPatchCommitHeader::FormatPercentH,
222        binary_format: LogPatchBinaryFormat::GitApplyBinaryPatch,
223        pathspecs: Vec::new(),
224        diff_merges: DiffMerges::FirstParent,
225    };
226    let mut abbrev_cache = OidAbbrevCache::new(opts.abbrev_len);
227    let oids = items
228        .iter()
229        .flat_map(|item| [item.entry.old_oid, item.entry.new_oid]);
230    abbrev_cache.warm_for_patch_batch(repo, oids, &thread_policy, &config)?;
231    for item in items {
232        write_diff_entry_patch_from_structured(
233            out,
234            repo,
235            odb,
236            &item.entry,
237            &log_opts,
238            &mut abbrev_cache,
239            item.binary,
240            &item.structured,
241        )?;
242    }
243    Ok(())
244}
245
246fn write_structured_line<W: Write>(out: &mut W, prefix: char, line: &StructuredLine) -> Result<()> {
247    write!(out, "{prefix}")?;
248    for segment in &line.segments {
249        write!(out, "{}", segment.text)?;
250    }
251    Ok(())
252}
253
254fn format_git_diff_hunk_side(start_1based: usize, line_count: usize) -> String {
255    if line_count == 1 {
256        format!("{start_1based}")
257    } else {
258        format!("{start_1based},{line_count}")
259    }
260}
261
262fn write_structured_unified_body<W: Write>(
263    out: &mut W,
264    git_old: &str,
265    git_new: &str,
266    old_mode: u32,
267    new_mode: u32,
268    structured: &StructuredFileDiff,
269) -> Result<()> {
270    const QUOTE_FULLY: bool = true;
271    let old_path = if old_mode == 0 {
272        "/dev/null".to_owned()
273    } else {
274        format_diff_path_with_prefix("a/", git_old, QUOTE_FULLY)
275    };
276    let new_path = if new_mode == 0 {
277        "/dev/null".to_owned()
278    } else {
279        format_diff_path_with_prefix("b/", git_new, QUOTE_FULLY)
280    };
281    writeln!(out, "--- {old_path}")?;
282    writeln!(out, "+++ {new_path}")?;
283    for hunk in &structured.hunks {
284        writeln!(
285            out,
286            "@@ -{} +{} @@",
287            format_git_diff_hunk_side(hunk.old_start, hunk.old_lines),
288            format_git_diff_hunk_side(hunk.new_start, hunk.new_lines),
289        )?;
290        for line in &hunk.lines {
291            let prefix = match line.kind {
292                StructuredLineKind::Context => ' ',
293                StructuredLineKind::Add => '+',
294                StructuredLineKind::Del => '-',
295            };
296            if line.no_newline_at_eof {
297                write_structured_line(out, prefix, line)?;
298                writeln!(out)?;
299                writeln!(out, "\\ No newline at end of file")?;
300            } else {
301                write_structured_line(out, prefix, line)?;
302                writeln!(out)?;
303            }
304        }
305    }
306    Ok(())
307}
308
309#[allow(clippy::too_many_arguments)]
310fn write_diff_entry_patch_from_structured<W: Write>(
311    out: &mut W,
312    repo: &Repository,
313    odb: &Odb,
314    entry: &DiffEntry,
315    opts: &LogPatchOptions,
316    abbrev_cache: &mut OidAbbrevCache,
317    is_binary: bool,
318    structured: &StructuredFileDiff,
319) -> Result<()> {
320    write_diff_entry_patch_headers(out, repo, entry, opts, abbrev_cache, is_binary)?;
321    let old_mode = log_patch_parse_mode_u32(&entry.old_mode);
322    let new_mode = log_patch_parse_mode_u32(&entry.new_mode);
323    let git_old = entry
324        .old_path
325        .as_deref()
326        .or(entry.new_path.as_deref())
327        .unwrap_or("dev/null");
328    let git_new = entry
329        .new_path
330        .as_deref()
331        .or(entry.old_path.as_deref())
332        .unwrap_or("dev/null");
333    if entry.old_oid == entry.new_oid {
334        return Ok(());
335    }
336    let use_structured = !is_binary
337        && !structured.hunks.is_empty()
338        && !structured.old_encoding_lossy
339        && !structured.new_encoding_lossy;
340    if use_structured {
341        write_structured_unified_body(out, git_old, git_new, old_mode, new_mode, structured)?;
342        return Ok(());
343    }
344    let old_bytes = read_blob_side(odb, entry.old_oid, old_mode == 0)?;
345    let new_bytes = read_blob_side(odb, entry.new_oid, new_mode == 0)?;
346    if old_bytes == new_bytes {
347        return Ok(());
348    }
349    if is_binary {
350        crate::apply::write_git_binary_patch_sections(out, &old_bytes, &new_bytes)?;
351        return Ok(());
352    }
353    write_patch_unified_body_from_bytes(
354        out, &old_bytes, &new_bytes, git_old, git_new, old_mode, new_mode, opts,
355    )
356}
357
358/// Unified hunk body for `git diff --binary` (no funcname context).
359#[allow(clippy::too_many_arguments)]
360fn write_patch_unified_body_from_bytes<W: Write>(
361    out: &mut W,
362    old_bytes: &[u8],
363    new_bytes: &[u8],
364    git_old: &str,
365    git_new: &str,
366    old_mode: u32,
367    new_mode: u32,
368    opts: &LogPatchOptions,
369) -> Result<()> {
370    const QUOTE_FULLY: bool = true;
371    let byte_oriented = !crate::commit_encoding::is_strict_utf8(old_bytes)
372        || !crate::commit_encoding::is_strict_utf8(new_bytes);
373    let (old_text, new_text) = if byte_oriented {
374        (
375            crate::commit_encoding::decode_latin1(old_bytes),
376            crate::commit_encoding::decode_latin1(new_bytes),
377        )
378    } else {
379        let old_s = std::str::from_utf8(old_bytes)
380            .map_err(|_| crate::error::Error::CorruptObject("blob is not valid UTF-8".into()))?;
381        let new_s = std::str::from_utf8(new_bytes)
382            .map_err(|_| crate::error::Error::CorruptObject("blob is not valid UTF-8".into()))?;
383        (old_s.to_owned(), new_s.to_owned())
384    };
385    let old_path = if old_mode == 0 {
386        "/dev/null".to_owned()
387    } else {
388        git_old.to_owned()
389    };
390    let new_path = if new_mode == 0 {
391        "/dev/null".to_owned()
392    } else {
393        git_new.to_owned()
394    };
395    let d = &opts.diff;
396    if byte_oriented {
397        let mut patch_buf = Vec::new();
398        super::write_unified_diff_from_diff_options(
399            &mut patch_buf,
400            &old_text,
401            &new_text,
402            &old_path,
403            &new_path,
404            "a/",
405            "b/",
406            None,
407            d,
408            QUOTE_FULLY,
409        )?;
410        let patch_str = std::str::from_utf8(&patch_buf)
411            .map_err(|_| crate::error::Error::CorruptObject("patch re-encoding failed".into()))?;
412        out.write_all(&crate::commit_encoding::encode_latin1_lossy(patch_str))?;
413    } else {
414        super::write_unified_diff_from_diff_options(
415            out,
416            &old_text,
417            &new_text,
418            &old_path,
419            &new_path,
420            "a/",
421            "b/",
422            None,
423            d,
424            QUOTE_FULLY,
425        )?;
426    }
427    Ok(())
428}
429
430fn resolve_core_abbrev_len(config: &ConfigSet, odb: &Odb) -> Result<usize> {
431    if let Some(v) = config.get("core.abbrev") {
432        if v.eq_ignore_ascii_case("auto") {
433            let count = approximate_object_count(odb)?;
434            return Ok(auto_abbrev_len_for_object_count(count));
435        }
436        if let Ok(n) = v.parse::<i64>() {
437            return Ok((n as usize).clamp(4, 40));
438        }
439    }
440    let count = approximate_object_count(odb)?;
441    Ok(auto_abbrev_len_for_object_count(count))
442}
443
444fn effective_log_patch_options(
445    repo: &Repository,
446    opts: &LogPatchOptions,
447) -> Result<LogPatchOptions> {
448    let config = repo.config()?.clone();
449    let mut out = opts.clone();
450    out.abbrev_len = resolve_core_abbrev_len(&config, repo.odb())?;
451    Ok(out)
452}
453
454fn log_patch_funcname_matcher(
455    repo: &Repository,
456    config: &ConfigSet,
457    tree_oid: &ObjectId,
458    rel_path: &str,
459) -> Result<Option<FuncnameMatcher>> {
460    let parsed = load_gitattributes_for_check_attr_source(repo, tree_oid)?;
461    matcher_for_path_parsed(config, &parsed.rules, &parsed.macros, rel_path, false)
462        .map_err(crate::error::Error::Message)
463}
464
465/// Write one commit in `git log -p --no-color --format=%H` shape (no patch for merges).
466///
467/// # Errors
468///
469/// Propagates ODB read, tree diff, and I/O errors.
470pub fn write_commit_log_patch<W: Write>(
471    repo: &Repository,
472    out: &mut W,
473    oid: &ObjectId,
474    opts: &LogPatchOptions,
475) -> Result<()> {
476    let odb = repo.odb();
477    let raw = odb.read(oid)?;
478    let commit = parse_commit(&raw.data)?;
479    let parent_tree = parent_tree_for_commit(odb, &commit, None)?;
480    let opts = effective_log_patch_options(repo, opts)?;
481    let mut abbrev_cache = OidAbbrevCache::new(opts.abbrev_len);
482    write_parsed_commit_log_patch(
483        out,
484        repo,
485        odb,
486        oid,
487        &commit,
488        parent_tree,
489        &opts,
490        &mut abbrev_cache,
491    )
492}
493
494/// Like [`write_commit_log_patch`] for many commits (avoids re-reading parent commits).
495///
496/// # Errors
497///
498/// Propagates ODB read, tree diff, and I/O errors.
499pub fn write_commits_log_patch<W: Write>(
500    repo: &Repository,
501    out: &mut W,
502    oids: &[ObjectId],
503    opts: &LogPatchOptions,
504) -> Result<()> {
505    if oids.is_empty() {
506        return Ok(());
507    }
508    let odb = repo.odb();
509    let mut trees_by_commit: HashMap<ObjectId, ObjectId> = HashMap::with_capacity(oids.len());
510    let mut parsed: Vec<(ObjectId, CommitData)> = Vec::with_capacity(oids.len());
511    for oid in oids {
512        let raw = odb.read(oid)?;
513        let commit = parse_commit(&raw.data)?;
514        trees_by_commit.insert(*oid, commit.tree);
515        parsed.push((*oid, commit));
516    }
517    let opts = effective_log_patch_options(repo, opts)?;
518    let mut abbrev_cache = OidAbbrevCache::new(opts.abbrev_len);
519    for (oid, commit) in &parsed {
520        let parent_tree = parent_tree_for_commit(odb, commit, Some(&trees_by_commit))?;
521        write_parsed_commit_log_patch(
522            out,
523            repo,
524            odb,
525            oid,
526            commit,
527            parent_tree,
528            &opts,
529            &mut abbrev_cache,
530        )?;
531    }
532    Ok(())
533}
534
535fn parent_tree_for_commit(
536    odb: &Odb,
537    commit: &CommitData,
538    trees_by_commit: Option<&HashMap<ObjectId, ObjectId>>,
539) -> Result<Option<ObjectId>> {
540    if commit.parents.is_empty() {
541        return Ok(None);
542    }
543    let parent = commit.parents[0];
544    if let Some(map) = trees_by_commit {
545        if let Some(tree) = map.get(&parent) {
546            return Ok(Some(*tree));
547        }
548    }
549    let parent_raw = odb.read(&parent)?;
550    Ok(Some(parse_commit(&parent_raw.data)?.tree))
551}
552
553fn abbrev_for_index_line(
554    cache: &mut OidAbbrevCache,
555    repo: &Repository,
556    oid: &ObjectId,
557    min_len: usize,
558) -> Result<String> {
559    if *oid == zero_oid() {
560        return Ok(abbrev_hex(oid, min_len));
561    }
562    cache.abbrev(repo, oid)
563}
564
565fn abbrev_index_pair(
566    cache: &mut OidAbbrevCache,
567    repo: &Repository,
568    old_oid: &ObjectId,
569    new_oid: &ObjectId,
570    min_len: usize,
571) -> Result<(String, String)> {
572    let old = abbrev_for_index_line(cache, repo, old_oid, min_len)?;
573    let new = abbrev_for_index_line(cache, repo, new_oid, min_len)?;
574    let len = old.len().max(new.len());
575    let pad = |oid: &ObjectId, abbrev: String| {
576        if abbrev.len() >= len {
577            return abbrev;
578        }
579        let hex = oid.to_hex();
580        hex[..len.min(hex.len())].to_string()
581    };
582    Ok((pad(old_oid, old), pad(new_oid, new)))
583}
584
585#[allow(clippy::too_many_arguments)]
586fn write_parsed_commit_log_patch<W: Write>(
587    out: &mut W,
588    repo: &Repository,
589    odb: &Odb,
590    oid: &ObjectId,
591    commit: &CommitData,
592    _parent_tree: Option<ObjectId>,
593    opts: &LogPatchOptions,
594    abbrev_cache: &mut OidAbbrevCache,
595) -> Result<()> {
596    let is_merge = commit.parents.len() > 1;
597    let omit_merge_patch = is_merge && opts.diff_merges == DiffMerges::Combined;
598    match opts.commit_header {
599        LogPatchCommitHeader::Full => {
600            writeln!(out, "commit {oid}")?;
601            if is_merge {
602                let mut parts = Vec::with_capacity(commit.parents.len());
603                for parent in &commit.parents {
604                    parts.push(abbrev_for_index_line(
605                        abbrev_cache,
606                        repo,
607                        parent,
608                        opts.abbrev_len,
609                    )?);
610                }
611                writeln!(out, "Merge: {}", parts.join(" "))?;
612            }
613            write_commit_header_and_message(out, &commit.author, &commit.message)?;
614            if omit_merge_patch {
615                return Ok(());
616            }
617            writeln!(out)?;
618        }
619        LogPatchCommitHeader::FormatPercentH => {
620            writeln!(out, "{oid}")?;
621            if omit_merge_patch {
622                return Ok(());
623            }
624            writeln!(out)?;
625        }
626    }
627    let config = repo.config()?.clone();
628    let thread_policy = repo.thread_policy();
629    let entries =
630        commit_first_parent_diff_entries(repo, commit, &opts.pathspecs, &config, &thread_policy)?;
631    for entry in entries {
632        write_diff_entry_patch(
633            out,
634            repo,
635            odb,
636            &entry,
637            opts,
638            abbrev_cache,
639            Some(&commit.tree),
640            opts.binary_format,
641            &config,
642        )?;
643    }
644    Ok(())
645}
646
647/// Write commit header (author, date, indented message) after the `commit` line.
648pub fn write_commit_header_and_message<W: Write>(
649    out: &mut W,
650    author_ident: &str,
651    message: &str,
652) -> Result<()> {
653    writeln!(out, "Author: {}", format_ident_display(author_ident))?;
654    writeln!(out, "Date:   {}", format_log_date(author_ident))?;
655    writeln!(out)?;
656    for line in message.lines() {
657        writeln!(out, "    {line}")?;
658    }
659    Ok(())
660}
661
662fn format_ident_display(ident: &str) -> String {
663    let name = ident.find('<').map_or(ident.trim(), |i| ident[..i].trim());
664    let email = ident
665        .find('<')
666        .and_then(|start| ident.find('>').map(|end| &ident[start..=end]))
667        .unwrap_or("");
668    format!("{name} {email}")
669}
670
671fn format_log_date(ident: &str) -> String {
672    let parts: Vec<&str> = ident.rsplitn(3, ' ').collect();
673    if parts.len() < 2 {
674        return String::new();
675    }
676    let ts_str = parts[1];
677    let offset_str = parts[0];
678    let Ok(ts) = ts_str.parse::<i64>() else {
679        return format!("{ts_str} {offset_str}");
680    };
681    let tz_bytes = offset_str.as_bytes();
682    let tz_secs: i64 = if tz_bytes.len() >= 5 {
683        let sign = if tz_bytes[0] == b'-' { -1 } else { 1 };
684        let h: i64 = offset_str[1..3].parse().unwrap_or(0);
685        let m: i64 = offset_str[3..5].parse().unwrap_or(0);
686        sign * (h * 3600 + m * 60)
687    } else {
688        0
689    };
690    let adjusted = ts + tz_secs;
691    let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
692        .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
693    let weekday = match dt.weekday() {
694        time::Weekday::Monday => "Mon",
695        time::Weekday::Tuesday => "Tue",
696        time::Weekday::Wednesday => "Wed",
697        time::Weekday::Thursday => "Thu",
698        time::Weekday::Friday => "Fri",
699        time::Weekday::Saturday => "Sat",
700        time::Weekday::Sunday => "Sun",
701    };
702    let month = match dt.month() {
703        time::Month::January => "Jan",
704        time::Month::February => "Feb",
705        time::Month::March => "Mar",
706        time::Month::April => "Apr",
707        time::Month::May => "May",
708        time::Month::June => "Jun",
709        time::Month::July => "Jul",
710        time::Month::August => "Aug",
711        time::Month::September => "Sep",
712        time::Month::October => "Oct",
713        time::Month::November => "Nov",
714        time::Month::December => "Dec",
715    };
716    format!(
717        "{weekday} {month} {} {:02}:{:02}:{:02} {} {offset_str}",
718        dt.day(),
719        dt.hour(),
720        dt.minute(),
721        dt.second(),
722        dt.year()
723    )
724}
725
726fn log_patch_parse_mode_u32(mode: &str) -> u32 {
727    u32::from_str_radix(mode.trim(), 8).unwrap_or(0)
728}
729
730/// Write the `diff --git` header and unified body for one [`DiffEntry`].
731///
732/// # Errors
733///
734/// Propagates ODB reads and I/O errors.
735pub fn write_diff_entry_log_patch<W: Write>(
736    out: &mut W,
737    repo: &Repository,
738    odb: &Odb,
739    entry: &DiffEntry,
740    opts: &LogPatchOptions,
741) -> Result<()> {
742    let config = repo.config()?.clone();
743    let mut abbrev_cache = OidAbbrevCache::new(opts.abbrev_len);
744    write_diff_entry_patch(
745        out,
746        repo,
747        odb,
748        entry,
749        opts,
750        &mut abbrev_cache,
751        None,
752        opts.binary_format,
753        &config,
754    )
755}
756
757fn index_oid_full(oid: &ObjectId) -> String {
758    oid.to_hex()
759}
760
761#[allow(clippy::too_many_arguments)]
762fn write_diff_entry_patch_headers<W: Write>(
763    out: &mut W,
764    repo: &Repository,
765    entry: &DiffEntry,
766    opts: &LogPatchOptions,
767    abbrev_cache: &mut OidAbbrevCache,
768    index_full_oid: bool,
769) -> Result<()> {
770    let git_old = entry
771        .old_path
772        .as_deref()
773        .or(entry.new_path.as_deref())
774        .unwrap_or("dev/null");
775    let git_new = entry
776        .new_path
777        .as_deref()
778        .or(entry.old_path.as_deref())
779        .unwrap_or("dev/null");
780    const QUOTE_FULLY: bool = true;
781    writeln!(
782        out,
783        "diff --git {} {}",
784        format_diff_path_with_prefix("a/", git_old, QUOTE_FULLY),
785        format_diff_path_with_prefix("b/", git_new, QUOTE_FULLY)
786    )?;
787
788    let old_mode = log_patch_parse_mode_u32(&entry.old_mode);
789    let new_mode = log_patch_parse_mode_u32(&entry.new_mode);
790
791    if old_mode == 0 {
792        writeln!(out, "new file mode {}", entry.new_mode.trim())?;
793    } else if new_mode == 0 {
794        writeln!(out, "deleted file mode {}", entry.old_mode.trim())?;
795    }
796
797    match entry.status {
798        DiffStatus::Renamed => {
799            writeln!(out, "similarity index {}%", entry.score.unwrap_or(100))?;
800            if let Some(path) = &entry.old_path {
801                writeln!(out, "rename from {path}")?;
802            }
803            if let Some(path) = &entry.new_path {
804                writeln!(out, "rename to {path}")?;
805            }
806        }
807        DiffStatus::Copied => {
808            writeln!(out, "similarity index {}%", entry.score.unwrap_or(100))?;
809            if let Some(path) = &entry.old_path {
810                writeln!(out, "copy from {path}")?;
811            }
812            if let Some(path) = &entry.new_path {
813                writeln!(out, "copy to {path}")?;
814            }
815        }
816        _ if old_mode != 0 && new_mode != 0 && old_mode != new_mode => {
817            writeln!(out, "old mode {}", entry.old_mode.trim())?;
818            writeln!(out, "new mode {}", entry.new_mode.trim())?;
819        }
820        _ => {}
821    }
822
823    if entry.old_oid != entry.new_oid {
824        let index_mode = if new_mode != 0 {
825            entry.new_mode.trim()
826        } else {
827            entry.old_mode.trim()
828        };
829        let mut index_pair = |old: &ObjectId, new: &ObjectId| -> Result<(String, String)> {
830            if index_full_oid {
831                Ok((index_oid_full(old), index_oid_full(new)))
832            } else {
833                abbrev_index_pair(abbrev_cache, repo, old, new, opts.abbrev_len)
834            }
835        };
836        match entry.status {
837            DiffStatus::Added => {
838                let (old_a, new_a) = index_pair(&zero_oid(), &entry.new_oid)?;
839                writeln!(out, "index {old_a}..{new_a}")?;
840            }
841            DiffStatus::Deleted => {
842                let (old_a, new_a) = index_pair(&entry.old_oid, &zero_oid())?;
843                writeln!(out, "index {old_a}..{new_a}")?;
844            }
845            DiffStatus::Renamed | DiffStatus::Copied => {
846                let (old_a, new_a) = index_pair(&entry.old_oid, &entry.new_oid)?;
847                writeln!(out, "index {old_a}..{new_a}")?;
848            }
849            _ => {
850                let (old_a, new_a) = index_pair(&entry.old_oid, &entry.new_oid)?;
851                writeln!(out, "index {old_a}..{new_a} {index_mode}")?;
852            }
853        }
854    }
855    Ok(())
856}
857
858#[allow(clippy::too_many_arguments)]
859fn write_diff_entry_patch<W: Write>(
860    out: &mut W,
861    repo: &Repository,
862    odb: &Odb,
863    entry: &DiffEntry,
864    opts: &LogPatchOptions,
865    abbrev_cache: &mut OidAbbrevCache,
866    new_tree: Option<&ObjectId>,
867    binary_format: LogPatchBinaryFormat,
868    config: &ConfigSet,
869) -> Result<()> {
870    let old_mode = log_patch_parse_mode_u32(&entry.old_mode);
871    let new_mode = log_patch_parse_mode_u32(&entry.new_mode);
872    let old_bytes = read_blob_side(odb, entry.old_oid, old_mode == 0)?;
873    let new_bytes = read_blob_side(odb, entry.new_oid, new_mode == 0)?;
874    write_diff_entry_patch_with_resolved_bytes(
875        out,
876        repo,
877        entry,
878        opts,
879        abbrev_cache,
880        binary_format,
881        config,
882        &old_bytes,
883        &new_bytes,
884        new_tree,
885    )
886}
887
888#[allow(clippy::too_many_arguments)]
889fn write_diff_entry_patch_with_resolved_bytes<W: Write>(
890    out: &mut W,
891    repo: &Repository,
892    entry: &DiffEntry,
893    opts: &LogPatchOptions,
894    abbrev_cache: &mut OidAbbrevCache,
895    binary_format: LogPatchBinaryFormat,
896    config: &ConfigSet,
897    old_bytes: &[u8],
898    new_bytes: &[u8],
899    new_tree: Option<&ObjectId>,
900) -> Result<()> {
901    const QUOTE_FULLY: bool = true;
902    let git_old = entry
903        .old_path
904        .as_deref()
905        .or(entry.new_path.as_deref())
906        .unwrap_or("dev/null");
907    let git_new = entry
908        .new_path
909        .as_deref()
910        .or(entry.old_path.as_deref())
911        .unwrap_or("dev/null");
912    let old_mode = log_patch_parse_mode_u32(&entry.old_mode);
913    let new_mode = log_patch_parse_mode_u32(&entry.new_mode);
914    let is_binary = merge_file::is_binary(old_bytes) || merge_file::is_binary(new_bytes);
915    let index_full_oid = is_binary && binary_format == LogPatchBinaryFormat::GitApplyBinaryPatch;
916    write_diff_entry_patch_headers(out, repo, entry, opts, abbrev_cache, index_full_oid)?;
917
918    if old_bytes == new_bytes {
919        return Ok(());
920    }
921
922    if is_binary {
923        match binary_format {
924            LogPatchBinaryFormat::LogShortMessage => {
925                let old_side = if old_mode == 0 {
926                    "/dev/null".to_owned()
927                } else {
928                    format_diff_path_with_prefix("a/", git_old, QUOTE_FULLY)
929                };
930                let new_side = if new_mode == 0 {
931                    "/dev/null".to_owned()
932                } else {
933                    format_diff_path_with_prefix("b/", git_new, QUOTE_FULLY)
934                };
935                writeln!(out, "Binary files {old_side} and {new_side} differ")?;
936            }
937            LogPatchBinaryFormat::GitApplyBinaryPatch => {
938                crate::apply::write_git_binary_patch_sections(out, old_bytes, new_bytes)?;
939            }
940        }
941        return Ok(());
942    }
943
944    if binary_format == LogPatchBinaryFormat::GitApplyBinaryPatch && new_tree.is_none() {
945        return write_patch_unified_body_from_bytes(
946            out, old_bytes, new_bytes, git_old, git_new, old_mode, new_mode, opts,
947        );
948    }
949    let byte_oriented = !crate::commit_encoding::is_strict_utf8(old_bytes)
950        || !crate::commit_encoding::is_strict_utf8(new_bytes);
951    let (old_text, new_text) = if byte_oriented {
952        (
953            crate::commit_encoding::decode_latin1(old_bytes),
954            crate::commit_encoding::decode_latin1(new_bytes),
955        )
956    } else {
957        let old_s = std::str::from_utf8(old_bytes)
958            .map_err(|_| crate::error::Error::CorruptObject("blob is not valid UTF-8".into()))?;
959        let new_s = std::str::from_utf8(new_bytes)
960            .map_err(|_| crate::error::Error::CorruptObject("blob is not valid UTF-8".into()))?;
961        (old_s.to_owned(), new_s.to_owned())
962    };
963    let old_path = if old_mode == 0 {
964        "/dev/null".to_owned()
965    } else {
966        git_old.to_owned()
967    };
968    let new_path = if new_mode == 0 {
969        "/dev/null".to_owned()
970    } else {
971        git_new.to_owned()
972    };
973    let d = &opts.diff;
974    let funcname_matcher = if let Some(tree) = new_tree {
975        log_patch_funcname_matcher(repo, config, tree, git_new)?
976    } else {
977        None
978    };
979    let funcname_ref = funcname_matcher.as_ref();
980    if byte_oriented {
981        let mut patch_buf = Vec::new();
982        super::write_unified_diff_from_diff_options(
983            &mut patch_buf,
984            &old_text,
985            &new_text,
986            &old_path,
987            &new_path,
988            "a/",
989            "b/",
990            funcname_ref,
991            d,
992            QUOTE_FULLY,
993        )?;
994        let patch_str = std::str::from_utf8(&patch_buf)
995            .map_err(|_| crate::error::Error::CorruptObject("patch re-encoding failed".into()))?;
996        out.write_all(&crate::commit_encoding::encode_latin1_lossy(patch_str))?;
997    } else {
998        super::write_unified_diff_from_diff_options(
999            out,
1000            &old_text,
1001            &new_text,
1002            &old_path,
1003            &new_path,
1004            "a/",
1005            "b/",
1006            funcname_ref,
1007            d,
1008            QUOTE_FULLY,
1009        )?;
1010    }
1011    Ok(())
1012}
1013
1014/// Where [`write_patch_resolved`] takes the content of one side of a file.
1015///
1016/// Pass [`PatchSide::Bytes`] for content already in memory. A caller that skipped loading
1017/// a side (diff collection usually drops binary content, which structured hunks never use)
1018/// names where the side lives instead, and the writer reads it only while rendering that
1019/// file. A side whose mode in [`PatchResolvedSide::entry`] is `000000` is empty whatever
1020/// variant is given.
1021#[derive(Debug, Clone, PartialEq, Eq)]
1022pub enum PatchSide {
1023    /// Content the caller already loaded, in repository form.
1024    Bytes(Vec<u8>),
1025    /// The blob named by this side's id in [`PatchResolvedSide::entry`], read from the
1026    /// object database.
1027    Blob,
1028    /// The work-tree file at this side's path, converted to repository form the way
1029    /// `git add` would (clean filters, `.gitattributes` and line-ending rules).
1030    Worktree,
1031}
1032
1033/// One file for [`write_patch_resolved`]: diff metadata plus where each side's content is.
1034#[derive(Debug, Clone)]
1035pub struct PatchResolvedSide {
1036    /// Tree diff metadata (paths, modes, ids, status).
1037    pub entry: DiffEntry,
1038    /// Old side content.
1039    pub old: PatchSide,
1040    /// New side content.
1041    pub new: PatchSide,
1042}
1043
1044/// Write `git diff --binary`-compatible patch text for files whose sides may live in memory,
1045/// the object database, or the work tree (index and work-tree diffs).
1046///
1047/// Binary files are written as `GIT binary patch` sections. Their `index` line always carries
1048/// full ids of the content actually written, hashed from the bytes for [`PatchSide::Bytes`] and
1049/// [`PatchSide::Worktree`] sides, because `git apply` refuses a binary patch without one and
1050/// work-tree entries often carry no blob id for the work-tree side.
1051///
1052/// # Parameters
1053///
1054/// - `repo`: repository for objects, config, and (for [`PatchSide::Worktree`]) the work tree
1055///   and index.
1056/// - `files`: files to write, in output order.
1057/// - `opts`: hunk and abbreviation settings.
1058/// - `out`: destination for patch bytes.
1059///
1060/// # Errors
1061///
1062/// Returns [`Error::RequiresWorkTree`](crate::error::Error::RequiresWorkTree) when a
1063/// [`PatchSide::Worktree`] side is requested in a bare repository, and propagates index, ODB,
1064/// work-tree, hashing and I/O errors.
1065pub fn write_patch_resolved<W: Write>(
1066    repo: &Repository,
1067    files: &[PatchResolvedSide],
1068    opts: &PatchTextOptions,
1069    out: &mut W,
1070) -> Result<()> {
1071    if files.is_empty() {
1072        return Ok(());
1073    }
1074    let config = repo.config()?.clone();
1075    let thread_policy = repo.thread_policy();
1076    let log_opts = LogPatchOptions {
1077        diff: opts.diff.clone(),
1078        abbrev_len: opts.abbrev_len,
1079        commit_header: LogPatchCommitHeader::FormatPercentH,
1080        binary_format: LogPatchBinaryFormat::GitApplyBinaryPatch,
1081        pathspecs: Vec::new(),
1082        diff_merges: DiffMerges::FirstParent,
1083    };
1084    let mut abbrev_cache = OidAbbrevCache::new(opts.abbrev_len);
1085    let oids = files
1086        .iter()
1087        .flat_map(|f| [f.entry.old_oid, f.entry.new_oid]);
1088    abbrev_cache.warm_for_patch_batch(repo, oids, &thread_policy, &config)?;
1089    let mut worktree = None;
1090    for file in files {
1091        let old = resolve_patch_side(repo, file, EntrySide::Old, &mut worktree)?;
1092        let new = resolve_patch_side(repo, file, EntrySide::New, &mut worktree)?;
1093        let entry = if merge_file::is_binary(&old) || merge_file::is_binary(&new) {
1094            Cow::Owned(entry_with_content_ids(repo, file, &old, &new)?)
1095        } else {
1096            Cow::Borrowed(&file.entry)
1097        };
1098        write_diff_entry_patch_with_resolved_bytes(
1099            out,
1100            repo,
1101            &entry,
1102            &log_opts,
1103            &mut abbrev_cache,
1104            LogPatchBinaryFormat::GitApplyBinaryPatch,
1105            &config,
1106            &old,
1107            &new,
1108            None,
1109        )?;
1110    }
1111    Ok(())
1112}
1113
1114#[derive(Clone, Copy)]
1115enum EntrySide {
1116    Old,
1117    New,
1118}
1119
1120impl EntrySide {
1121    fn of(self, file: &PatchResolvedSide) -> (&PatchSide, &str, ObjectId) {
1122        let e = &file.entry;
1123        match self {
1124            Self::Old => (&file.old, &e.old_mode, e.old_oid),
1125            Self::New => (&file.new, &e.new_mode, e.new_oid),
1126        }
1127    }
1128
1129    fn path(self, entry: &DiffEntry) -> &str {
1130        match self {
1131            Self::Old => entry.old_path.as_deref().unwrap_or_else(|| entry.path()),
1132            Self::New => entry.path(),
1133        }
1134    }
1135}
1136
1137/// Index and attribute rules for reading [`PatchSide::Worktree`] sides, loaded on first use.
1138struct WorktreeSideReader<'r> {
1139    work_tree: &'r std::path::Path,
1140    index: Index,
1141    rules: WorktreeRules,
1142}
1143
1144impl<'r> WorktreeSideReader<'r> {
1145    fn open(repo: &'r Repository) -> Result<Self> {
1146        let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
1147        let index = repo.index_handle().load()?;
1148        let rules = WorktreeRules::from_repository(repo, &index)?;
1149        Ok(Self {
1150            work_tree,
1151            index,
1152            rules,
1153        })
1154    }
1155
1156    fn read(&self, odb: &Odb, path: &str, mode: u32) -> Result<Vec<u8>> {
1157        let index_entry = self.index.get(&crate::repo_relpath::storage_bytes(path), 0);
1158        read_worktree_side_bytes_for_diff(
1159            odb,
1160            self.work_tree,
1161            path,
1162            mode,
1163            self.rules.config(),
1164            index_entry,
1165            Some(&self.rules),
1166        )
1167    }
1168}
1169
1170fn resolve_patch_side<'f, 'r>(
1171    repo: &'r Repository,
1172    file: &'f PatchResolvedSide,
1173    side: EntrySide,
1174    worktree: &mut Option<WorktreeSideReader<'r>>,
1175) -> Result<Cow<'f, [u8]>> {
1176    let (source, mode, oid) = side.of(file);
1177    let mode = log_patch_parse_mode_u32(mode);
1178    if mode == 0 {
1179        return Ok(Cow::Borrowed(&[]));
1180    }
1181    match source {
1182        PatchSide::Bytes(bytes) => Ok(Cow::Borrowed(bytes)),
1183        PatchSide::Blob => Ok(Cow::Owned(read_blob_side(repo.odb(), oid, false)?)),
1184        PatchSide::Worktree => {
1185            let reader = match worktree {
1186                Some(reader) => reader,
1187                None => worktree.insert(WorktreeSideReader::open(repo)?),
1188            };
1189            Ok(Cow::Owned(reader.read(
1190                repo.odb(),
1191                side.path(&file.entry),
1192                mode,
1193            )?))
1194        }
1195    }
1196}
1197
1198/// `file.entry` with each present side's id set to the id of the content being written.
1199fn entry_with_content_ids(
1200    repo: &Repository,
1201    file: &PatchResolvedSide,
1202    old: &[u8],
1203    new: &[u8],
1204) -> Result<DiffEntry> {
1205    let content_id = |side: EntrySide, bytes: &[u8]| -> Result<ObjectId> {
1206        let (source, mode, oid) = side.of(file);
1207        if log_patch_parse_mode_u32(mode) == 0 {
1208            return Ok(zero_oid());
1209        }
1210        match source {
1211            PatchSide::Blob => Ok(oid),
1212            PatchSide::Bytes(_) | PatchSide::Worktree => {
1213                repo.odb().hash_algo().hash_object(ObjectKind::Blob, bytes)
1214            }
1215        }
1216    };
1217    let mut entry = file.entry.clone();
1218    entry.old_oid = content_id(EntrySide::Old, old)?;
1219    entry.new_oid = content_id(EntrySide::New, new)?;
1220    Ok(entry)
1221}
1222
1223fn read_blob_side(odb: &Odb, oid: ObjectId, is_missing: bool) -> Result<Vec<u8>> {
1224    if is_missing || oid == zero_oid() || oid == empty_blob_oid() {
1225        return Ok(Vec::new());
1226    }
1227    let obj = odb.read(&oid)?;
1228    if obj.kind != ObjectKind::Blob {
1229        return Ok(Vec::new());
1230    }
1231    Ok(obj.data)
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236    use super::*;
1237    use std::io::Cursor;
1238
1239    use crate::hash::hash_object;
1240    use crate::objects::HashAlgo;
1241
1242    #[test]
1243    fn copied_diff_emits_copy_headers() -> Result<()> {
1244        let blob_oid = hash_object(HashAlgo::Sha1, ObjectKind::Blob, b"x").expect("hash");
1245        let entry = DiffEntry {
1246            status: DiffStatus::Copied,
1247            old_path: Some("src/a.txt".into()),
1248            new_path: Some("dst/b.txt".into()),
1249            old_oid: blob_oid,
1250            new_oid: blob_oid,
1251            old_mode: "100644".into(),
1252            new_mode: "100644".into(),
1253            score: Some(100),
1254        };
1255        let dir = tempfile::tempdir().expect("tempdir");
1256        let repo = Repository::init(dir.path(), &crate::repo::InitOptions::default())?;
1257        repo.odb().write(ObjectKind::Blob, b"x")?;
1258        let mut out = Cursor::new(Vec::new());
1259        let opts = LogPatchOptions::git_default(&ConfigSet::default());
1260        write_diff_entry_log_patch(&mut out, &repo, repo.odb(), &entry, &opts)?;
1261        let text = String::from_utf8(out.into_inner()).expect("utf-8");
1262        assert!(text.contains("copy from src/a.txt"));
1263        assert!(text.contains("copy to dst/b.txt"));
1264        assert!(!text.contains("rename from"));
1265        Ok(())
1266    }
1267}