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