Skip to main content

torii_lib/vcs/
patch.rs

1//! `torii patch` — export commits as patch files and apply them.
2//!
3//! Two subcommands:
4//!
5//!   - `torii patch export <range>` → ≡ `git format-patch <range>`
6//!     Produces one `.patch` file per commit, suitable for email or
7//!     archive.
8//!   - `torii patch apply <file>...` → ≡ `git am <file>...`
9//!     Applies one or more `.patch` files as new commits, preserving
10//!     authorship and message.
11//!
12//! Wrapper rationale: same as `subtree` and `archive`. `git format-patch`
13//! / `git am` have decades of edge-case handling around mailbox parsing,
14//! base64 binary blobs, 3-way fallback, etc. Reimplementing those on top
15//! of libgit2 would be 800-1500 LOC of risk; the wrapper is ~80.
16
17use crate::error::{Result, ToriiError};
18use std::path::{Path, PathBuf};
19use std::process::Command;
20
21// -- export -----------------------------------------------------------------
22
23#[derive(Debug, Default)]
24pub struct ExportOpts {
25    /// Output directory for the patch files. Default is cwd.
26    pub output_dir: Option<PathBuf>,
27    /// `--stdout` — write all patches to stdout instead of files.
28    pub stdout: bool,
29    /// Add a cover letter (`--cover-letter`).
30    pub cover_letter: bool,
31}
32
33pub fn export(repo_path: &Path, range: &str, opts: &ExportOpts) -> Result<()> {
34    let mut args = vec!["format-patch".to_string()];
35    if let Some(dir) = &opts.output_dir {
36        args.push("-o".to_string());
37        args.push(dir.to_string_lossy().to_string());
38    }
39    if opts.stdout {
40        args.push("--stdout".to_string());
41    }
42    if opts.cover_letter {
43        args.push("--cover-letter".to_string());
44    }
45    args.push(range.to_string());
46
47    println!("📨 patch export  range={range}");
48    let status = Command::new("git")
49        .args(&args)
50        .current_dir(repo_path)
51        .status()
52        .map_err(|e| ToriiError::Subprocess { tool: "git".into(), message: format!("invoke git format-patch: {e}") })?;
53    if !status.success() {
54        return Err(ToriiError::Subprocess { tool: "git".into(), message: format!(
55            "git format-patch exited with {status}"
56        ) });
57    }
58    Ok(())
59}
60
61// -- apply ------------------------------------------------------------------
62
63#[derive(Debug, Default)]
64pub struct ApplyOpts {
65    /// `--3way` — try 3-way merge if the patch doesn't apply cleanly.
66    pub three_way: bool,
67    /// `--abort` — bail out of an in-progress `am` session.
68    pub abort: bool,
69    /// `--continue` — resume after resolving conflicts.
70    pub continue_: bool,
71    /// `--skip` — drop the current patch and move on.
72    pub skip: bool,
73}
74
75pub fn apply(repo_path: &Path, files: &[PathBuf], opts: &ApplyOpts) -> Result<()> {
76    let mut args = vec!["am".to_string()];
77    if opts.three_way {
78        args.push("--3way".to_string());
79    }
80    if opts.abort {
81        args.push("--abort".to_string());
82    } else if opts.continue_ {
83        args.push("--continue".to_string());
84    } else if opts.skip {
85        args.push("--skip".to_string());
86    } else {
87        if files.is_empty() {
88            return Err(ToriiError::Usage(
89                "patch apply needs at least one file, or --abort / --continue / --skip".into(),
90            ));
91        }
92        for f in files {
93            args.push(f.to_string_lossy().to_string());
94        }
95    }
96
97    println!("📨 patch apply  ({} arg(s))", args.len() - 1);
98    let status = Command::new("git")
99        .args(&args)
100        .current_dir(repo_path)
101        .status()
102        .map_err(|e| ToriiError::Subprocess { tool: "git".into(), message: format!("invoke git am: {e}") })?;
103    if !status.success() {
104        return Err(ToriiError::Subprocess { tool: "git".into(), message: format!(
105            "git am exited with {status} — resolve and run `torii patch apply --continue`"
106        ) });
107    }
108    Ok(())
109}