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