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::{abbrev_hex, resolve_core_abbrev_len};
11use crate::config::ConfigSet;
12use crate::diff::options::DiffOptions;
13use crate::error::{Error, Result};
14use crate::index::Index;
15use crate::merge_file;
16use crate::objects::CommitData;
17use crate::objects::FileMode;
18use crate::objects::{parse_commit, ObjectId, ObjectKind};
19use crate::odb::Odb;
20use crate::oid_abbrev_cache::OidAbbrevCache;
21use crate::pack_store::PackReadContextGuard;
22use crate::parallel::{run_ordered_batches, ParallelOp};
23use crate::quote_path::format_diff_path_with_prefix;
24use crate::repo::Repository;
25use crate::userdiff::{matcher_for_path_parsed, FuncnameMatcher};
26use crate::worktree_rules::WorktreeRules;
27
28use super::merge_commit::commit_first_parent_diff_entries;
29use super::merge_commit::DiffMerges;
30use super::tree_content::load_odb_blob_side_for_diff;
31use super::{empty_blob_oid, load_worktree_side_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    super::tree_content::prefetch_tree_diff_blobs(odb, entries)?;
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 path = entry.new_path.as_deref().or(entry.old_path.as_deref());
346    let force_read = is_binary;
347    let old_bytes = read_blob_side(odb, entry.old_oid, old_mode == 0, path, force_read)?;
348    let new_bytes = read_blob_side(odb, entry.new_oid, new_mode == 0, path, force_read)?;
349    if old_bytes == new_bytes {
350        return Ok(());
351    }
352    if is_binary {
353        crate::apply::write_git_binary_patch_sections(out, &old_bytes, &new_bytes)?;
354        return Ok(());
355    }
356    write_patch_unified_body_from_bytes(
357        out, &old_bytes, &new_bytes, git_old, git_new, old_mode, new_mode, opts,
358    )
359}
360
361/// Unified hunk body for `git diff --binary` (no funcname context).
362#[allow(clippy::too_many_arguments)]
363fn write_patch_unified_body_from_bytes<W: Write>(
364    out: &mut W,
365    old_bytes: &[u8],
366    new_bytes: &[u8],
367    git_old: &str,
368    git_new: &str,
369    old_mode: u32,
370    new_mode: u32,
371    opts: &LogPatchOptions,
372) -> Result<()> {
373    const QUOTE_FULLY: bool = true;
374    let byte_oriented = !crate::commit_encoding::is_strict_utf8(old_bytes)
375        || !crate::commit_encoding::is_strict_utf8(new_bytes);
376    let (old_text, new_text) = if byte_oriented {
377        (
378            crate::commit_encoding::decode_latin1(old_bytes),
379            crate::commit_encoding::decode_latin1(new_bytes),
380        )
381    } else {
382        let old_s = std::str::from_utf8(old_bytes)
383            .map_err(|_| crate::error::Error::CorruptObject("blob is not valid UTF-8".into()))?;
384        let new_s = std::str::from_utf8(new_bytes)
385            .map_err(|_| crate::error::Error::CorruptObject("blob is not valid UTF-8".into()))?;
386        (old_s.to_owned(), new_s.to_owned())
387    };
388    let old_path = if old_mode == 0 {
389        "/dev/null".to_owned()
390    } else {
391        git_old.to_owned()
392    };
393    let new_path = if new_mode == 0 {
394        "/dev/null".to_owned()
395    } else {
396        git_new.to_owned()
397    };
398    let d = &opts.diff;
399    if byte_oriented {
400        let mut patch_buf = Vec::new();
401        super::write_unified_diff_from_diff_options(
402            &mut patch_buf,
403            &old_text,
404            &new_text,
405            &old_path,
406            &new_path,
407            "a/",
408            "b/",
409            None,
410            d,
411            QUOTE_FULLY,
412        )?;
413        let patch_str = std::str::from_utf8(&patch_buf)
414            .map_err(|_| crate::error::Error::CorruptObject("patch re-encoding failed".into()))?;
415        out.write_all(&crate::commit_encoding::encode_latin1_lossy(patch_str))?;
416    } else {
417        super::write_unified_diff_from_diff_options(
418            out,
419            &old_text,
420            &new_text,
421            &old_path,
422            &new_path,
423            "a/",
424            "b/",
425            None,
426            d,
427            QUOTE_FULLY,
428        )?;
429    }
430    Ok(())
431}
432
433fn effective_log_patch_options(
434    repo: &Repository,
435    opts: &LogPatchOptions,
436) -> Result<LogPatchOptions> {
437    let config = repo.config()?.clone();
438    let mut out = opts.clone();
439    out.abbrev_len = resolve_core_abbrev_len(&config, repo.odb())?;
440    Ok(out)
441}
442
443fn log_patch_funcname_matcher(
444    repo: &Repository,
445    config: &ConfigSet,
446    tree_oid: &ObjectId,
447    rel_path: &str,
448) -> Result<Option<FuncnameMatcher>> {
449    let parsed = load_gitattributes_for_check_attr_source(repo, tree_oid)?;
450    matcher_for_path_parsed(config, &parsed.rules, &parsed.macros, rel_path, false)
451        .map_err(crate::error::Error::Message)
452}
453
454/// Write one commit in `git log -p --no-color --format=%H` shape (no patch for merges).
455///
456/// # Errors
457///
458/// Propagates ODB read, tree diff, and I/O errors.
459pub fn write_commit_log_patch<W: Write>(
460    repo: &Repository,
461    out: &mut W,
462    oid: &ObjectId,
463    opts: &LogPatchOptions,
464) -> Result<()> {
465    let odb = repo.odb();
466    let raw = odb.read(oid)?;
467    let commit = parse_commit(&raw.data)?;
468    let parent_ctx = crate::shallow::EffectiveCommitParents::load(repo);
469    let parent_tree = parent_tree_for_commit(repo, &parent_ctx, oid, &commit, None)?;
470    let opts = effective_log_patch_options(repo, opts)?;
471    let mut abbrev_cache = OidAbbrevCache::new(opts.abbrev_len);
472    write_parsed_commit_log_patch(
473        out,
474        repo,
475        odb,
476        oid,
477        &commit,
478        parent_tree,
479        &parent_ctx,
480        &opts,
481        &mut abbrev_cache,
482    )
483}
484
485/// Like [`write_commit_log_patch`] for many commits (avoids re-reading parent commits).
486///
487/// With [`LogPatchCommitHeader::Full`] a blank line separates consecutive commits, as in
488/// `git log -p`; [`LogPatchCommitHeader::FormatPercentH`] writes none, as in
489/// `git log -p --format=%H`.
490///
491/// # Errors
492///
493/// Propagates ODB read, tree diff, and I/O errors.
494pub fn write_commits_log_patch<W: Write>(
495    repo: &Repository,
496    out: &mut W,
497    oids: &[ObjectId],
498    opts: &LogPatchOptions,
499) -> Result<()> {
500    if oids.is_empty() {
501        return Ok(());
502    }
503    let odb = repo.odb();
504    let mut trees_by_commit: HashMap<ObjectId, ObjectId> = HashMap::with_capacity(oids.len());
505    let mut parsed: Vec<(ObjectId, CommitData)> = Vec::with_capacity(oids.len());
506    for oid in oids {
507        let raw = odb.read(oid)?;
508        let commit = parse_commit(&raw.data)?;
509        trees_by_commit.insert(*oid, commit.tree);
510        parsed.push((*oid, commit));
511    }
512    let opts = effective_log_patch_options(repo, opts)?;
513    let parent_ctx = crate::shallow::EffectiveCommitParents::load(repo);
514    let mut abbrev_cache = OidAbbrevCache::new(opts.abbrev_len);
515    for (i, (oid, commit)) in parsed.iter().enumerate() {
516        if i > 0 && opts.commit_header == LogPatchCommitHeader::Full {
517            writeln!(out)?;
518        }
519        let parent_tree =
520            parent_tree_for_commit(repo, &parent_ctx, oid, 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            &parent_ctx,
529            &opts,
530            &mut abbrev_cache,
531        )?;
532    }
533    Ok(())
534}
535
536fn parent_tree_for_commit(
537    repo: &Repository,
538    parent_ctx: &crate::shallow::EffectiveCommitParents,
539    commit_oid: &ObjectId,
540    commit: &CommitData,
541    trees_by_commit: Option<&HashMap<ObjectId, ObjectId>>,
542) -> Result<Option<ObjectId>> {
543    let effective_parents = parent_ctx.for_commit(*commit_oid, &commit.parents);
544    if effective_parents.is_empty() {
545        return Ok(None);
546    }
547    let parent = effective_parents[0];
548    let odb = repo.odb();
549    if let Some(map) = trees_by_commit {
550        if let Some(tree) = map.get(&parent) {
551            return Ok(Some(*tree));
552        }
553    }
554    let parent_raw = odb.read(&parent)?;
555    Ok(Some(parse_commit(&parent_raw.data)?.tree))
556}
557
558fn abbrev_for_index_line(
559    cache: &mut OidAbbrevCache,
560    repo: &Repository,
561    oid: &ObjectId,
562    min_len: usize,
563) -> Result<String> {
564    if *oid == zero_oid() {
565        return Ok(abbrev_hex(oid, min_len));
566    }
567    cache.abbrev(repo, oid)
568}
569
570fn abbrev_index_pair(
571    cache: &mut OidAbbrevCache,
572    repo: &Repository,
573    old_oid: &ObjectId,
574    new_oid: &ObjectId,
575    min_len: usize,
576) -> Result<(String, String)> {
577    let old = abbrev_for_index_line(cache, repo, old_oid, min_len)?;
578    let new = abbrev_for_index_line(cache, repo, new_oid, min_len)?;
579    let len = old.len().max(new.len());
580    let pad = |oid: &ObjectId, abbrev: String| {
581        if abbrev.len() >= len {
582            return abbrev;
583        }
584        let hex = oid.to_hex();
585        hex[..len.min(hex.len())].to_string()
586    };
587    Ok((pad(old_oid, old), pad(new_oid, new)))
588}
589
590#[allow(clippy::too_many_arguments)]
591fn write_parsed_commit_log_patch<W: Write>(
592    out: &mut W,
593    repo: &Repository,
594    odb: &Odb,
595    oid: &ObjectId,
596    commit: &CommitData,
597    _parent_tree: Option<ObjectId>,
598    parent_ctx: &crate::shallow::EffectiveCommitParents,
599    opts: &LogPatchOptions,
600    abbrev_cache: &mut OidAbbrevCache,
601) -> Result<()> {
602    let effective_parents = parent_ctx.for_commit(*oid, &commit.parents);
603    let is_merge = effective_parents.len() > 1;
604    let omit_merge_patch = is_merge && opts.diff_merges == DiffMerges::Combined;
605    match opts.commit_header {
606        LogPatchCommitHeader::Full => {
607            writeln!(out, "commit {oid}")?;
608            if is_merge {
609                let mut parts = Vec::with_capacity(effective_parents.len());
610                for parent in &effective_parents {
611                    parts.push(abbrev_for_index_line(
612                        abbrev_cache,
613                        repo,
614                        parent,
615                        opts.abbrev_len,
616                    )?);
617                }
618                writeln!(out, "Merge: {}", parts.join(" "))?;
619            }
620            write_commit_header_and_message(out, &commit.author, &commit.message)?;
621            if omit_merge_patch {
622                return Ok(());
623            }
624            writeln!(out)?;
625        }
626        LogPatchCommitHeader::FormatPercentH => {
627            writeln!(out, "{oid}")?;
628            if omit_merge_patch {
629                return Ok(());
630            }
631            writeln!(out)?;
632        }
633    }
634    let config = repo.config()?.clone();
635    let thread_policy = repo.thread_policy();
636    let entries = commit_first_parent_diff_entries(
637        repo,
638        oid,
639        commit,
640        &opts.pathspecs,
641        &config,
642        &thread_policy,
643    )?;
644    for entry in entries {
645        write_diff_entry_patch(
646            out,
647            repo,
648            odb,
649            &entry,
650            opts,
651            abbrev_cache,
652            Some(&commit.tree),
653            opts.binary_format,
654            &config,
655        )?;
656    }
657    Ok(())
658}
659
660/// Write commit header (author, date, indented message) after the `commit` line.
661pub fn write_commit_header_and_message<W: Write>(
662    out: &mut W,
663    author_ident: &str,
664    message: &str,
665) -> Result<()> {
666    writeln!(out, "Author: {}", format_ident_display(author_ident))?;
667    writeln!(out, "Date:   {}", format_log_date(author_ident))?;
668    writeln!(out)?;
669    for line in message.lines() {
670        writeln!(out, "    {line}")?;
671    }
672    Ok(())
673}
674
675fn format_ident_display(ident: &str) -> String {
676    let name = ident.find('<').map_or(ident.trim(), |i| ident[..i].trim());
677    let email = ident
678        .find('<')
679        .and_then(|start| ident.find('>').map(|end| &ident[start..=end]))
680        .unwrap_or("");
681    format!("{name} {email}")
682}
683
684fn format_log_date(ident: &str) -> String {
685    let parts: Vec<&str> = ident.rsplitn(3, ' ').collect();
686    if parts.len() < 2 {
687        return String::new();
688    }
689    let ts_str = parts[1];
690    let offset_str = parts[0];
691    let Ok(ts) = ts_str.parse::<i64>() else {
692        return format!("{ts_str} {offset_str}");
693    };
694    let tz_bytes = offset_str.as_bytes();
695    let tz_secs: i64 = if tz_bytes.len() >= 5 {
696        let sign = if tz_bytes[0] == b'-' { -1 } else { 1 };
697        let h: i64 = offset_str[1..3].parse().unwrap_or(0);
698        let m: i64 = offset_str[3..5].parse().unwrap_or(0);
699        sign * (h * 3600 + m * 60)
700    } else {
701        0
702    };
703    let adjusted = ts + tz_secs;
704    let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
705        .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
706    let weekday = match dt.weekday() {
707        time::Weekday::Monday => "Mon",
708        time::Weekday::Tuesday => "Tue",
709        time::Weekday::Wednesday => "Wed",
710        time::Weekday::Thursday => "Thu",
711        time::Weekday::Friday => "Fri",
712        time::Weekday::Saturday => "Sat",
713        time::Weekday::Sunday => "Sun",
714    };
715    let month = match dt.month() {
716        time::Month::January => "Jan",
717        time::Month::February => "Feb",
718        time::Month::March => "Mar",
719        time::Month::April => "Apr",
720        time::Month::May => "May",
721        time::Month::June => "Jun",
722        time::Month::July => "Jul",
723        time::Month::August => "Aug",
724        time::Month::September => "Sep",
725        time::Month::October => "Oct",
726        time::Month::November => "Nov",
727        time::Month::December => "Dec",
728    };
729    format!(
730        "{weekday} {month} {} {:02}:{:02}:{:02} {} {offset_str}",
731        dt.day(),
732        dt.hour(),
733        dt.minute(),
734        dt.second(),
735        dt.year()
736    )
737}
738
739/// Write the `diff --git` header and unified body for one [`DiffEntry`].
740///
741/// # Errors
742///
743/// Propagates ODB reads and I/O errors.
744pub fn write_diff_entry_log_patch<W: Write>(
745    out: &mut W,
746    repo: &Repository,
747    odb: &Odb,
748    entry: &DiffEntry,
749    opts: &LogPatchOptions,
750) -> Result<()> {
751    let config = repo.config()?.clone();
752    let mut abbrev_cache = OidAbbrevCache::new(opts.abbrev_len);
753    write_diff_entry_patch(
754        out,
755        repo,
756        odb,
757        entry,
758        opts,
759        &mut abbrev_cache,
760        None,
761        opts.binary_format,
762        &config,
763    )
764}
765
766fn index_oid_full(oid: &ObjectId) -> String {
767    oid.to_hex()
768}
769
770#[allow(clippy::too_many_arguments)]
771fn write_diff_entry_patch_headers<W: Write>(
772    out: &mut W,
773    repo: &Repository,
774    entry: &DiffEntry,
775    opts: &LogPatchOptions,
776    abbrev_cache: &mut OidAbbrevCache,
777    index_full_oid: bool,
778) -> Result<()> {
779    let git_old = entry
780        .old_path
781        .as_deref()
782        .or(entry.new_path.as_deref())
783        .unwrap_or("dev/null");
784    let git_new = entry
785        .new_path
786        .as_deref()
787        .or(entry.old_path.as_deref())
788        .unwrap_or("dev/null");
789    const QUOTE_FULLY: bool = true;
790    writeln!(
791        out,
792        "diff --git {} {}",
793        format_diff_path_with_prefix("a/", git_old, QUOTE_FULLY),
794        format_diff_path_with_prefix("b/", git_new, QUOTE_FULLY)
795    )?;
796
797    let old_mode = entry.old_mode.bits();
798    let new_mode = entry.new_mode.bits();
799
800    if old_mode == 0 {
801        writeln!(out, "new file mode {}", entry.new_mode)?;
802    } else if new_mode == 0 {
803        writeln!(out, "deleted file mode {}", entry.old_mode)?;
804    }
805
806    match entry.status {
807        DiffStatus::Renamed => {
808            writeln!(out, "similarity index {}%", entry.score.unwrap_or(100))?;
809            if let Some(path) = &entry.old_path {
810                writeln!(out, "rename from {path}")?;
811            }
812            if let Some(path) = &entry.new_path {
813                writeln!(out, "rename to {path}")?;
814            }
815        }
816        DiffStatus::Copied => {
817            writeln!(out, "similarity index {}%", entry.score.unwrap_or(100))?;
818            if let Some(path) = &entry.old_path {
819                writeln!(out, "copy from {path}")?;
820            }
821            if let Some(path) = &entry.new_path {
822                writeln!(out, "copy to {path}")?;
823            }
824        }
825        _ if old_mode != 0 && new_mode != 0 && old_mode != new_mode => {
826            writeln!(out, "old mode {}", entry.old_mode)?;
827            writeln!(out, "new mode {}", entry.new_mode)?;
828        }
829        _ => {}
830    }
831
832    if entry.old_oid != entry.new_oid {
833        let index_mode = if new_mode != 0 {
834            entry.new_mode
835        } else {
836            entry.old_mode
837        };
838        let mut index_pair = |old: &ObjectId, new: &ObjectId| -> Result<(String, String)> {
839            if index_full_oid {
840                Ok((index_oid_full(old), index_oid_full(new)))
841            } else {
842                abbrev_index_pair(abbrev_cache, repo, old, new, opts.abbrev_len)
843            }
844        };
845        match entry.status {
846            DiffStatus::Added => {
847                let (old_a, new_a) = index_pair(&zero_oid(), &entry.new_oid)?;
848                writeln!(out, "index {old_a}..{new_a}")?;
849            }
850            DiffStatus::Deleted => {
851                let (old_a, new_a) = index_pair(&entry.old_oid, &zero_oid())?;
852                writeln!(out, "index {old_a}..{new_a}")?;
853            }
854            DiffStatus::Renamed | DiffStatus::Copied => {
855                let (old_a, new_a) = index_pair(&entry.old_oid, &entry.new_oid)?;
856                writeln!(out, "index {old_a}..{new_a}")?;
857            }
858            _ => {
859                let (old_a, new_a) = index_pair(&entry.old_oid, &entry.new_oid)?;
860                writeln!(out, "index {old_a}..{new_a} {index_mode}")?;
861            }
862        }
863    }
864    Ok(())
865}
866
867#[allow(clippy::too_many_arguments)]
868fn write_diff_entry_patch<W: Write>(
869    out: &mut W,
870    repo: &Repository,
871    odb: &Odb,
872    entry: &DiffEntry,
873    opts: &LogPatchOptions,
874    abbrev_cache: &mut OidAbbrevCache,
875    new_tree: Option<&ObjectId>,
876    binary_format: LogPatchBinaryFormat,
877    config: &ConfigSet,
878) -> Result<()> {
879    let old_mode = entry.old_mode.bits();
880    let new_mode = entry.new_mode.bits();
881    let path = entry.new_path.as_deref().or(entry.old_path.as_deref());
882    let force_read = binary_format == LogPatchBinaryFormat::GitApplyBinaryPatch;
883    let (old_bytes, old_bin) =
884        load_patch_blob_side(odb, entry.old_oid, old_mode == 0, path, force_read)?;
885    let (new_bytes, new_bin) =
886        load_patch_blob_side(odb, entry.new_oid, new_mode == 0, path, force_read)?;
887    write_diff_entry_patch_with_resolved_bytes(
888        out,
889        repo,
890        entry,
891        opts,
892        abbrev_cache,
893        binary_format,
894        config,
895        old_bin || new_bin,
896        &old_bytes,
897        &new_bytes,
898        new_tree,
899    )
900}
901
902#[allow(clippy::too_many_arguments)]
903fn write_diff_entry_patch_with_resolved_bytes<W: Write>(
904    out: &mut W,
905    repo: &Repository,
906    entry: &DiffEntry,
907    opts: &LogPatchOptions,
908    abbrev_cache: &mut OidAbbrevCache,
909    binary_format: LogPatchBinaryFormat,
910    config: &ConfigSet,
911    side_known_binary: bool,
912    old_bytes: &[u8],
913    new_bytes: &[u8],
914    new_tree: Option<&ObjectId>,
915) -> Result<()> {
916    const QUOTE_FULLY: bool = true;
917    let git_old = entry
918        .old_path
919        .as_deref()
920        .or(entry.new_path.as_deref())
921        .unwrap_or("dev/null");
922    let git_new = entry
923        .new_path
924        .as_deref()
925        .or(entry.old_path.as_deref())
926        .unwrap_or("dev/null");
927    let old_mode = entry.old_mode.bits();
928    let new_mode = entry.new_mode.bits();
929    let is_binary =
930        side_known_binary || merge_file::is_binary(old_bytes) || merge_file::is_binary(new_bytes);
931    let index_full_oid = is_binary && binary_format == LogPatchBinaryFormat::GitApplyBinaryPatch;
932    write_diff_entry_patch_headers(out, repo, entry, opts, abbrev_cache, index_full_oid)?;
933
934    if old_bytes == new_bytes && entry.old_oid == entry.new_oid {
935        return Ok(());
936    }
937
938    if is_binary {
939        match binary_format {
940            LogPatchBinaryFormat::LogShortMessage => {
941                let old_side = if old_mode == 0 {
942                    "/dev/null".to_owned()
943                } else {
944                    format_diff_path_with_prefix("a/", git_old, QUOTE_FULLY)
945                };
946                let new_side = if new_mode == 0 {
947                    "/dev/null".to_owned()
948                } else {
949                    format_diff_path_with_prefix("b/", git_new, QUOTE_FULLY)
950                };
951                writeln!(out, "Binary files {old_side} and {new_side} differ")?;
952            }
953            LogPatchBinaryFormat::GitApplyBinaryPatch => {
954                crate::apply::write_git_binary_patch_sections(out, old_bytes, new_bytes)?;
955            }
956        }
957        return Ok(());
958    }
959
960    if binary_format == LogPatchBinaryFormat::GitApplyBinaryPatch && new_tree.is_none() {
961        return write_patch_unified_body_from_bytes(
962            out, old_bytes, new_bytes, git_old, git_new, old_mode, new_mode, opts,
963        );
964    }
965    let byte_oriented = !crate::commit_encoding::is_strict_utf8(old_bytes)
966        || !crate::commit_encoding::is_strict_utf8(new_bytes);
967    let (old_text, new_text) = if byte_oriented {
968        (
969            crate::commit_encoding::decode_latin1(old_bytes),
970            crate::commit_encoding::decode_latin1(new_bytes),
971        )
972    } else {
973        let old_s = std::str::from_utf8(old_bytes)
974            .map_err(|_| crate::error::Error::CorruptObject("blob is not valid UTF-8".into()))?;
975        let new_s = std::str::from_utf8(new_bytes)
976            .map_err(|_| crate::error::Error::CorruptObject("blob is not valid UTF-8".into()))?;
977        (old_s.to_owned(), new_s.to_owned())
978    };
979    let old_path = if old_mode == 0 {
980        "/dev/null".to_owned()
981    } else {
982        git_old.to_owned()
983    };
984    let new_path = if new_mode == 0 {
985        "/dev/null".to_owned()
986    } else {
987        git_new.to_owned()
988    };
989    let d = &opts.diff;
990    let funcname_matcher = if let Some(tree) = new_tree {
991        log_patch_funcname_matcher(repo, config, tree, git_new)?
992    } else {
993        None
994    };
995    let funcname_ref = funcname_matcher.as_ref();
996    if byte_oriented {
997        let mut patch_buf = Vec::new();
998        super::write_unified_diff_from_diff_options(
999            &mut patch_buf,
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        let patch_str = std::str::from_utf8(&patch_buf)
1011            .map_err(|_| crate::error::Error::CorruptObject("patch re-encoding failed".into()))?;
1012        out.write_all(&crate::commit_encoding::encode_latin1_lossy(patch_str))?;
1013    } else {
1014        super::write_unified_diff_from_diff_options(
1015            out,
1016            &old_text,
1017            &new_text,
1018            &old_path,
1019            &new_path,
1020            "a/",
1021            "b/",
1022            funcname_ref,
1023            d,
1024            QUOTE_FULLY,
1025        )?;
1026    }
1027    Ok(())
1028}
1029
1030/// Where [`write_patch_resolved`] takes the content of one side of a file.
1031///
1032/// Pass [`PatchSide::Bytes`] for content already in memory. A caller that skipped loading
1033/// a side (diff collection usually drops binary content, which structured hunks never use)
1034/// names where the side lives instead, and the writer reads it only while rendering that
1035/// file. A side whose mode in [`PatchResolvedSide::entry`] is `000000` is empty whatever
1036/// variant is given.
1037#[derive(Debug, Clone, PartialEq, Eq)]
1038pub enum PatchSide {
1039    /// Content the caller already loaded, in repository form.
1040    Bytes(Vec<u8>),
1041    /// The blob named by this side's id in [`PatchResolvedSide::entry`], read from the
1042    /// object database.
1043    Blob,
1044    /// The work-tree file at this side's path, converted to repository form the way
1045    /// `git add` would (clean filters, `.gitattributes` and line-ending rules).
1046    Worktree,
1047}
1048
1049/// One file for [`write_patch_resolved`]: diff metadata plus where each side's content is.
1050#[derive(Debug, Clone)]
1051pub struct PatchResolvedSide {
1052    /// Tree diff metadata (paths, modes, ids, status).
1053    pub entry: DiffEntry,
1054    /// Old side content.
1055    pub old: PatchSide,
1056    /// New side content.
1057    pub new: PatchSide,
1058}
1059
1060/// Write `git diff --binary`-compatible patch text for files whose sides may live in memory,
1061/// the object database, or the work tree (index and work-tree diffs).
1062///
1063/// Binary files are written as `GIT binary patch` sections. The `index` line carries abbreviated
1064/// blob ids for the content actually written: [`PatchSide::Blob`] keeps the entry oid,
1065/// [`PatchSide::Bytes`] and [`PatchSide::Worktree`] are hashed at render time (no ODB write),
1066/// gitlinks keep their commit oids, and absent sides stay zero. Work-tree diffs often arrive
1067/// with a zero new oid even for text files; filling ids here keeps `git apply --3way` usable.
1068///
1069/// # Parameters
1070///
1071/// - `repo`: repository for objects, config, and (for [`PatchSide::Worktree`]) the work tree
1072///   and index.
1073/// - `files`: files to write, in output order.
1074/// - `opts`: hunk and abbreviation settings.
1075/// - `out`: destination for patch bytes.
1076///
1077/// # Errors
1078///
1079/// Returns [`Error::RequiresWorkTree`](crate::error::Error::RequiresWorkTree) when a
1080/// [`PatchSide::Worktree`] side is requested in a bare repository, and propagates index, ODB,
1081/// work-tree, hashing and I/O errors.
1082pub fn write_patch_resolved<W: Write>(
1083    repo: &Repository,
1084    files: &[PatchResolvedSide],
1085    opts: &PatchTextOptions,
1086    out: &mut W,
1087) -> Result<()> {
1088    if files.is_empty() {
1089        return Ok(());
1090    }
1091    let config = repo.config()?.clone();
1092    let thread_policy = repo.thread_policy();
1093    let log_opts = LogPatchOptions {
1094        diff: opts.diff.clone(),
1095        abbrev_len: opts.abbrev_len,
1096        commit_header: LogPatchCommitHeader::FormatPercentH,
1097        binary_format: LogPatchBinaryFormat::GitApplyBinaryPatch,
1098        pathspecs: Vec::new(),
1099        diff_merges: DiffMerges::FirstParent,
1100    };
1101    let mut abbrev_cache = OidAbbrevCache::new(opts.abbrev_len);
1102    let mut worktree = None;
1103    for file in files {
1104        let old = resolve_patch_side(repo, file, EntrySide::Old, &mut worktree)?;
1105        let new = resolve_patch_side(repo, file, EntrySide::New, &mut worktree)?;
1106        let entry = entry_with_content_ids(repo, file, &old, &new)?;
1107        abbrev_cache.warm_for_patch_batch(
1108            repo,
1109            [entry.old_oid, entry.new_oid],
1110            &thread_policy,
1111            &config,
1112        )?;
1113        write_diff_entry_patch_with_resolved_bytes(
1114            out,
1115            repo,
1116            &entry,
1117            &log_opts,
1118            &mut abbrev_cache,
1119            LogPatchBinaryFormat::GitApplyBinaryPatch,
1120            &config,
1121            false,
1122            &old,
1123            &new,
1124            None,
1125        )?;
1126    }
1127    Ok(())
1128}
1129
1130#[derive(Clone, Copy)]
1131enum EntrySide {
1132    Old,
1133    New,
1134}
1135
1136impl EntrySide {
1137    fn of(self, file: &PatchResolvedSide) -> (&PatchSide, FileMode, ObjectId) {
1138        let e = &file.entry;
1139        match self {
1140            Self::Old => (&file.old, e.old_mode, e.old_oid),
1141            Self::New => (&file.new, e.new_mode, e.new_oid),
1142        }
1143    }
1144
1145    fn path(self, entry: &DiffEntry) -> &str {
1146        match self {
1147            Self::Old => entry.old_path.as_deref().unwrap_or_else(|| entry.path()),
1148            Self::New => entry.path(),
1149        }
1150    }
1151}
1152
1153/// Index and attribute rules for reading [`PatchSide::Worktree`] sides, loaded on first use.
1154struct WorktreeSideReader<'r> {
1155    work_tree: &'r std::path::Path,
1156    index: Index,
1157    rules: WorktreeRules,
1158}
1159
1160impl<'r> WorktreeSideReader<'r> {
1161    fn open(repo: &'r Repository) -> Result<Self> {
1162        let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
1163        let index = repo.index_handle().load()?;
1164        let rules = WorktreeRules::from_repository(repo, &index)?;
1165        Ok(Self {
1166            work_tree,
1167            index,
1168            rules,
1169        })
1170    }
1171
1172    fn read(&self, odb: &Odb, path: &str, mode: u32) -> Result<Vec<u8>> {
1173        let index_entry = self.index.get(&crate::repo_relpath::storage_bytes(path), 0);
1174        Ok(load_worktree_side_for_diff(
1175            odb,
1176            self.work_tree,
1177            path,
1178            mode,
1179            self.rules.config(),
1180            index_entry,
1181            Some(&self.rules),
1182            true,
1183        )?
1184        .0
1185        .data)
1186    }
1187}
1188
1189fn resolve_patch_side<'f, 'r>(
1190    repo: &'r Repository,
1191    file: &'f PatchResolvedSide,
1192    side: EntrySide,
1193    worktree: &mut Option<WorktreeSideReader<'r>>,
1194) -> Result<Cow<'f, [u8]>> {
1195    let (source, mode, oid) = side.of(file);
1196    let mode = mode.bits();
1197    if mode == 0 {
1198        return Ok(Cow::Borrowed(&[]));
1199    }
1200    match source {
1201        PatchSide::Bytes(bytes) => Ok(Cow::Borrowed(bytes)),
1202        PatchSide::Blob => Ok(Cow::Owned(read_blob_side(
1203            repo.odb(),
1204            oid,
1205            false,
1206            Some(side.path(&file.entry)),
1207            true,
1208        )?)),
1209        PatchSide::Worktree => {
1210            let reader = match worktree {
1211                Some(reader) => reader,
1212                None => worktree.insert(WorktreeSideReader::open(repo)?),
1213            };
1214            Ok(Cow::Owned(reader.read(
1215                repo.odb(),
1216                side.path(&file.entry),
1217                mode,
1218            )?))
1219        }
1220    }
1221}
1222
1223/// `file.entry` with each present side's id set to the id of the content being written.
1224fn entry_with_content_ids(
1225    repo: &Repository,
1226    file: &PatchResolvedSide,
1227    old: &[u8],
1228    new: &[u8],
1229) -> Result<DiffEntry> {
1230    let content_id = |side: EntrySide, bytes: &[u8]| -> Result<ObjectId> {
1231        let (source, mode, oid) = side.of(file);
1232        if mode.is_absent() {
1233            return Ok(zero_oid());
1234        }
1235        if mode.is_gitlink() {
1236            return Ok(oid);
1237        }
1238        match source {
1239            PatchSide::Blob => Ok(oid),
1240            PatchSide::Bytes(_) | PatchSide::Worktree => {
1241                repo.odb().hash_algo().hash_object(ObjectKind::Blob, bytes)
1242            }
1243        }
1244    };
1245    let mut entry = file.entry.clone();
1246    entry.old_oid = content_id(EntrySide::Old, old)?;
1247    entry.new_oid = content_id(EntrySide::New, new)?;
1248    Ok(entry)
1249}
1250
1251fn load_patch_blob_side(
1252    odb: &Odb,
1253    oid: ObjectId,
1254    is_missing: bool,
1255    path: Option<&str>,
1256    force_read_content: bool,
1257) -> Result<(Vec<u8>, bool)> {
1258    if is_missing || oid == zero_oid() || oid == empty_blob_oid() {
1259        return Ok((Vec::new(), false));
1260    }
1261    let (side, binary) = load_odb_blob_side_for_diff(odb, &oid, path, force_read_content)?;
1262    Ok((side.data, binary))
1263}
1264
1265fn read_blob_side(
1266    odb: &Odb,
1267    oid: ObjectId,
1268    is_missing: bool,
1269    path: Option<&str>,
1270    force_read_content: bool,
1271) -> Result<Vec<u8>> {
1272    Ok(load_patch_blob_side(odb, oid, is_missing, path, force_read_content)?.0)
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277    use super::*;
1278    use std::io::Cursor;
1279
1280    use crate::hash::hash_object;
1281    use crate::objects::HashAlgo;
1282
1283    #[test]
1284    fn log_short_message_skips_big_blob_reads() -> Result<()> {
1285        let dir = tempfile::tempdir().expect("tempdir");
1286        let repo = Repository::init(dir.path(), &crate::repo::InitOptions::default())?;
1287        std::fs::write(
1288            repo.git_dir().join("config"),
1289            "[core]\n\tbigFileThreshold = 4k\n",
1290        )?;
1291        let odb = repo.odb();
1292        let old = odb.write(ObjectKind::Blob, &vec![b'o'; 5000])?;
1293        let new = odb.write(ObjectKind::Blob, &vec![b'n'; 5001])?;
1294        let entry = DiffEntry {
1295            status: DiffStatus::Modified,
1296            old_path: Some("asset.bin".into()),
1297            new_path: Some("asset.bin".into()),
1298            old_oid: old,
1299            new_oid: new,
1300            old_mode: FileMode::REGULAR,
1301            new_mode: FileMode::REGULAR,
1302            score: None,
1303        };
1304        let metrics = odb.hot_path_metrics();
1305        metrics.reset_blob_content_reads();
1306        metrics.set_blob_counting(true);
1307        let mut out = Cursor::new(Vec::new());
1308        let mut opts = LogPatchOptions::git_default(&ConfigSet::default());
1309        opts.binary_format = LogPatchBinaryFormat::LogShortMessage;
1310        write_diff_entry_log_patch(&mut out, &repo, odb, &entry, &opts)?;
1311        metrics.set_blob_counting(false);
1312        let text = String::from_utf8(out.into_inner()).expect("utf-8");
1313        assert!(text.contains("Binary files"));
1314        assert_eq!(metrics.blob_content_reads(), 0);
1315        Ok(())
1316    }
1317
1318    #[test]
1319    fn copied_diff_emits_copy_headers() -> Result<()> {
1320        let blob_oid = hash_object(HashAlgo::Sha1, ObjectKind::Blob, b"x").expect("hash");
1321        let entry = DiffEntry {
1322            status: DiffStatus::Copied,
1323            old_path: Some("src/a.txt".into()),
1324            new_path: Some("dst/b.txt".into()),
1325            old_oid: blob_oid,
1326            new_oid: blob_oid,
1327            old_mode: FileMode::REGULAR,
1328            new_mode: FileMode::REGULAR,
1329            score: Some(100),
1330        };
1331        let dir = tempfile::tempdir().expect("tempdir");
1332        let repo = Repository::init(dir.path(), &crate::repo::InitOptions::default())?;
1333        repo.odb().write(ObjectKind::Blob, b"x")?;
1334        let mut out = Cursor::new(Vec::new());
1335        let opts = LogPatchOptions::git_default(&ConfigSet::default());
1336        write_diff_entry_log_patch(&mut out, &repo, repo.odb(), &entry, &opts)?;
1337        let text = String::from_utf8(out.into_inner()).expect("utf-8");
1338        assert!(text.contains("copy from src/a.txt"));
1339        assert!(text.contains("copy to dst/b.txt"));
1340        assert!(!text.contains("rename from"));
1341        Ok(())
1342    }
1343
1344    #[test]
1345    fn write_patch_resolved_index_line_hashes_text_bytes_when_new_oid_zero() -> Result<()> {
1346        let dir = tempfile::tempdir().expect("tempdir");
1347        let repo = Repository::init(dir.path(), &crate::repo::InitOptions::default())?;
1348        let old_oid = repo.odb().write(ObjectKind::Blob, b"one\n")?;
1349        let new_bytes = b"one\ntwo\n".to_vec();
1350        let new_oid = repo
1351            .odb()
1352            .hash_algo()
1353            .hash_object(ObjectKind::Blob, &new_bytes)?;
1354        let files = [PatchResolvedSide {
1355            entry: DiffEntry {
1356                status: DiffStatus::Modified,
1357                old_path: Some("t.txt".into()),
1358                new_path: Some("t.txt".into()),
1359                old_oid,
1360                new_oid: zero_oid(),
1361                old_mode: FileMode::REGULAR,
1362                new_mode: FileMode::REGULAR,
1363                score: None,
1364            },
1365            old: PatchSide::Bytes(b"one\n".to_vec()),
1366            new: PatchSide::Bytes(new_bytes),
1367        }];
1368        let opts = PatchTextOptions::from_repo(&repo, DiffOptions::default())?;
1369        let mut out = Cursor::new(Vec::new());
1370        write_patch_resolved(&repo, &files, &opts, &mut out)?;
1371        let text = String::from_utf8(out.into_inner()).expect("utf-8");
1372        assert!(
1373            !text.contains("..0000000 100644"),
1374            "must not emit placeholder new oid:\n{text}"
1375        );
1376        assert!(
1377            text.contains(&format!("..{} 100644", &new_oid.to_hex()[..7])),
1378            "index line must abbreviate hashed postimage:\n{text}"
1379        );
1380        Ok(())
1381    }
1382
1383    #[test]
1384    fn write_patch_resolved_gitlink_index_line_keeps_commit_ids() -> Result<()> {
1385        let dir = tempfile::tempdir().expect("tempdir");
1386        let repo = Repository::init(dir.path(), &crate::repo::InitOptions::default())?;
1387        let old_commit =
1388            ObjectId::from_hex("1111111111111111111111111111111111111111").expect("oid");
1389        let new_commit =
1390            ObjectId::from_hex("2222222222222222222222222222222222222222").expect("oid");
1391        let files = [PatchResolvedSide {
1392            entry: DiffEntry {
1393                status: DiffStatus::Modified,
1394                old_path: Some("sub".into()),
1395                new_path: Some("sub".into()),
1396                old_oid: old_commit,
1397                new_oid: new_commit,
1398                old_mode: FileMode::GITLINK,
1399                new_mode: FileMode::GITLINK,
1400                score: None,
1401            },
1402            old: PatchSide::Bytes(format!("Subproject commit {}\n", old_commit.to_hex()).into()),
1403            new: PatchSide::Bytes(format!("Subproject commit {}\n", new_commit.to_hex()).into()),
1404        }];
1405        let opts = PatchTextOptions::from_repo(&repo, DiffOptions::default())?;
1406        let mut out = Cursor::new(Vec::new());
1407        write_patch_resolved(&repo, &files, &opts, &mut out)?;
1408        let text = String::from_utf8(out.into_inner()).expect("utf-8");
1409        assert!(
1410            text.contains(&format!(
1411                "index {}..{} 160000",
1412                &old_commit.to_hex()[..7],
1413                &new_commit.to_hex()[..7]
1414            )),
1415            "gitlink index line must keep commit oids:\n{text}"
1416        );
1417        Ok(())
1418    }
1419}