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