use crate::cli::global::GlobalFlags;
use crate::diff::{DiffResult, format_diff_result_colored};
use crate::exit;
use crate::ops::patch::{
ApplyHunksOptions, ApplyHunksResult, ApplyHunksStatus, OnStale, apply_hunks,
apply_hunks_with_options, parse_patch,
};
use crate::plan::Operation;
use crate::tx::engine::WriteSource;
use clap::Args;
use serde::Serialize;
#[derive(Debug, Args)]
#[command(after_help = "\
EXAMPLES:
patchloom patch apply changes.patch
patchloom patch apply changes.patch --apply
patchloom patch check changes.patch
patchloom patch merge changes.patch --check
patchloom patch merge changes.patch --apply --allow-conflicts")]
pub struct PatchArgs {
#[command(subcommand)]
pub action: PatchAction,
#[command(flatten)]
pub write: crate::cli::global::WriteFlags,
}
#[derive(Debug, clap::Subcommand)]
pub enum PatchAction {
Check {
file: Option<String>,
#[arg(long)]
stdin: bool,
},
Apply {
file: Option<String>,
#[arg(long)]
stdin: bool,
#[arg(long, value_enum, default_value_t = OnStaleCli::Fail)]
on_stale: OnStaleCli,
},
Merge {
file: Option<String>,
#[arg(long)]
stdin: bool,
#[arg(long)]
allow_conflicts: bool,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum OnStaleCli {
#[default]
Fail,
Merge,
}
impl From<OnStaleCli> for OnStale {
fn from(value: OnStaleCli) -> Self {
match value {
OnStaleCli::Fail => OnStale::Fail,
OnStaleCli::Merge => OnStale::Merge,
}
}
}
enum DiffReadError {
NoSource,
IoError(String, std::io::Error),
StdinError(std::io::Error),
InvalidInput(String),
}
fn classify_diff_bytes(bytes: Vec<u8>, display: &str) -> Result<String, DiffReadError> {
match crate::files::classify_text_bytes(&bytes) {
crate::files::TextBytesKind::Text(s) => Ok(s),
crate::files::TextBytesKind::Binary => Err(DiffReadError::InvalidInput(format!(
"patch input is a binary file: {display}"
))),
crate::files::TextBytesKind::InvalidUtf8 => Err(DiffReadError::InvalidInput(format!(
"patch input is not valid UTF-8 text: {display}"
))),
}
}
fn read_diff_stdin() -> Result<String, DiffReadError> {
use std::io::Read;
let mut bytes = Vec::new();
std::io::stdin()
.read_to_end(&mut bytes)
.map_err(DiffReadError::StdinError)?;
classify_diff_bytes(bytes, "stdin")
}
fn read_diff_input(
file: &Option<String>,
stdin_flag: bool,
global: &GlobalFlags,
) -> Result<String, DiffReadError> {
if let Some(path) = file {
if path == "-" {
read_diff_stdin()
} else {
let full = global
.resolve_user_path(path)
.map_err(|e| DiffReadError::IoError(path.clone(), std::io::Error::other(e)))?;
let display = full.display().to_string();
crate::files::load_text_strict(&full, &display).map_err(|e| {
if crate::exit::is_invalid_input(&e) {
DiffReadError::InvalidInput(e.to_string())
} else if crate::exit::is_io_not_found(&e) {
DiffReadError::IoError(
display.clone(),
std::io::Error::new(std::io::ErrorKind::NotFound, e.to_string()),
)
} else {
DiffReadError::IoError(display, std::io::Error::other(e.to_string()))
}
})
}
} else if stdin_flag {
read_diff_stdin()
} else {
Err(DiffReadError::NoSource)
}
}
#[derive(Debug)]
enum PatchTargetError {
NotFound,
NotAFile(String),
InvalidInput(String),
Io(String),
}
fn load_patch_target(
path: &std::path::Path,
display: &str,
missing_as_empty: bool,
) -> Result<String, PatchTargetError> {
match crate::files::load_text_strict(path, display) {
Ok(s) => Ok(s),
Err(e) if crate::exit::is_io_not_found(&e) => {
if missing_as_empty {
Ok(String::new())
} else {
Err(PatchTargetError::NotFound)
}
}
Err(e) if crate::exit::is_invalid_input(&e) => {
let msg = e.to_string();
if msg.starts_with("target is not a file:") {
Err(PatchTargetError::NotAFile(msg))
} else {
Err(PatchTargetError::InvalidInput(msg))
}
}
Err(e) => {
Err(PatchTargetError::Io(crate::exit::agent_error_message(&e)))
}
}
}
#[derive(Debug, Clone, Serialize)]
struct PatchFileResult {
path: String,
status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
conflicts: Option<usize>,
}
#[derive(Debug, Serialize)]
struct PatchFilesOutput {
ok: bool,
files: Vec<PatchFileResult>,
#[serde(skip_serializing_if = "Option::is_none")]
error_kind: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
applied: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
backup_session: Option<String>,
}
fn patch_file_result(path: &str, applied: &ApplyHunksResult) -> PatchFileResult {
PatchFileResult {
path: path.to_string(),
status: applied.status.as_str(),
error: None,
conflicts: if applied.conflicts.is_empty() {
None
} else {
Some(applied.conflicts.len())
},
}
}
fn build_file_results(
diffs: &[crate::diff::FileDiff],
status: &'static str,
) -> Vec<PatchFileResult> {
diffs
.iter()
.filter(|d| d.has_changes)
.map(|d| PatchFileResult {
path: d.path.clone(),
status,
error: None,
conflicts: None,
})
.collect()
}
fn apply_patch_file(
original: &str,
hunks: &[crate::ops::patch::Hunk],
options: ApplyHunksOptions,
) -> Result<ApplyHunksResult, String> {
apply_hunks_with_options(original, hunks, options)
}
fn inject_stale_label(msg: &str, label: &str) -> String {
if let Some(idx) = msg.find(" -- ") {
let (prefix, rest) = msg.split_at(idx + 4);
format!("{prefix}{label}: {rest}")
} else {
format!("{msg} ({label})")
}
}
fn emit_error(global: &GlobalFlags, error: &str, error_kind: &str) -> anyhow::Result<()> {
if !global.emit_json(&serde_json::json!({
"ok": false,
"error": error,
"error_kind": error_kind,
}))? && !global.quiet
{
eprintln!("{error}");
}
Ok(())
}
fn patch_problem_error_kind(results: &[PatchFileResult]) -> (&'static str, String) {
let has_stale = results.iter().any(|r| r.status == "stale");
let has_missing = results.iter().any(|r| r.status == "missing");
let has_error = results.iter().any(|r| r.status == "error");
let has_conflict = results.iter().any(|r| r.status == "conflict");
if has_conflict {
(
"conflicts",
"one or more patch targets have merge conflicts".into(),
)
} else if has_stale {
(
"ambiguous",
"one or more patch targets are stale (context no longer matches)".into(),
)
} else if has_missing && !has_error {
("not_found", "one or more patch targets are missing".into())
} else if has_error {
(
"invalid_input",
"one or more patch targets could not be read".into(),
)
} else {
("ambiguous", "one or more patch targets failed".into())
}
}
fn emit_patch_files_output(
global: &GlobalFlags,
ok: bool,
results: &[PatchFileResult],
applied: Option<bool>,
backup_session: Option<String>,
) -> anyhow::Result<()> {
if global.json {
let (error_kind, error) = if ok {
(None, None)
} else {
let (k, e) = patch_problem_error_kind(results);
(Some(k), Some(e))
};
let output = PatchFilesOutput {
ok,
files: results.to_vec(),
error_kind,
error,
applied,
backup_session,
};
global.emit_json(&output)?;
} else if global.jsonl {
global.emit_json_items(results)?;
} else if !global.quiet {
for r in results {
let label = match r.status {
"clean" | "unchanged" => "clean",
"would_change" => "would change",
"stale" => "STALE",
"missing" => "MISSING",
"error" => "ERROR",
"conflict" => "CONFLICT",
"applied" => "applied",
other => other,
};
if let Some(err) = &r.error {
eprintln!("patch check: {} -- {}: {}", r.path, label, err);
} else if let Some(n) = r.conflicts {
eprintln!("patch check: {} -- {} ({} conflicts)", r.path, label, n);
} else if r.status != "clean" && r.status != "unchanged" && r.status != "applied" {
eprintln!("patch check: {} -- {}", r.path, label);
}
}
}
Ok(())
}
pub fn run(args: PatchArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
crate::verbose!(
"patch: action={:?}, apply={}, check={}",
std::mem::discriminant(&args.action),
global.apply,
global.check
);
let (file, stdin_flag, merge_mode, apply_options) = match &args.action {
PatchAction::Check { file, stdin } => {
(file.clone(), *stdin, false, ApplyHunksOptions::default())
}
PatchAction::Apply {
file,
stdin,
on_stale,
} => (
file.clone(),
*stdin,
false,
ApplyHunksOptions {
on_stale: (*on_stale).into(),
allow_conflicts: false,
},
),
PatchAction::Merge {
file,
stdin,
allow_conflicts,
} => (
file.clone(),
*stdin,
true,
ApplyHunksOptions {
on_stale: OnStale::Merge,
allow_conflicts: *allow_conflicts,
},
),
};
let cwd = global.resolve_cwd()?;
let diff_text = match read_diff_input(&file, stdin_flag, global) {
Ok(text) => text,
Err(DiffReadError::NoSource) => {
emit_error(
global,
"patch: must specify --file <path> or --stdin",
"parse_error",
)?;
return Ok(exit::PARSE_ERROR);
}
Err(DiffReadError::IoError(path, e)) => {
let (kind, code) = if e.kind() == std::io::ErrorKind::NotFound {
("not_found", exit::FAILURE)
} else {
("parse_error", exit::PARSE_ERROR)
};
let msg = {
let detail = e.to_string();
if detail.contains("failed to read") {
format!("patch: {detail}")
} else {
format!("patch: failed to read '{path}': {detail}")
}
};
emit_error(global, &msg, kind)?;
return Ok(code);
}
Err(DiffReadError::StdinError(e)) => {
emit_error(
global,
&format!("patch: failed to read stdin: {e}"),
"parse_error",
)?;
return Ok(exit::PARSE_ERROR);
}
Err(DiffReadError::InvalidInput(msg)) => {
emit_error(global, &format!("patch: {msg}"), "invalid_input")?;
return Ok(exit::FAILURE);
}
};
crate::verbose!("patch: diff text length={}", diff_text.len());
let patch_files = match parse_patch(&diff_text) {
Ok(pf) => pf,
Err(msg) => {
emit_error(global, &format!("patch: parse error: {msg}"), "parse_error")?;
return Ok(exit::PARSE_ERROR);
}
};
crate::verbose!(
"patch: parsed {} file(s), merge_mode={}",
patch_files.len(),
merge_mode
);
if matches!(args.action, PatchAction::Check { .. }) {
let mut any_would_change = false;
let mut any_problem = false;
let mut results = Vec::new();
for pf in &patch_files {
let file_path = cwd.join(&pf.path);
let original = match load_patch_target(&file_path, &pf.path, pf.is_creation) {
Ok(s) => s,
Err(PatchTargetError::NotFound) => {
let msg = format!("file not found: {}", file_path.display());
results.push(PatchFileResult {
path: pf.path.clone(),
status: "missing",
error: Some(msg.clone()),
conflicts: None,
});
any_problem = true;
continue;
}
Err(PatchTargetError::InvalidInput(msg)) => {
global.emit_error_json_kind(Some("invalid_input"), &msg)?;
return Ok(exit::FAILURE);
}
Err(PatchTargetError::NotAFile(msg) | PatchTargetError::Io(msg)) => {
results.push(PatchFileResult {
path: pf.path.clone(),
status: "error",
error: Some(msg.clone()),
conflicts: None,
});
if !global.json && !global.jsonl && !global.quiet {
eprintln!("patch check: {} -- READ ERROR: {}", pf.path, msg);
}
any_problem = true;
continue;
}
};
match apply_hunks(&original, &pf.hunks) {
Ok(new_content) if new_content == original => {
results.push(PatchFileResult {
path: pf.path.clone(),
status: "unchanged",
error: None,
conflicts: None,
});
}
Ok(_) => {
any_would_change = true;
results.push(PatchFileResult {
path: pf.path.clone(),
status: "would_change",
error: None,
conflicts: None,
});
}
Err(_) => {
any_problem = true;
results.push(PatchFileResult {
path: pf.path.clone(),
status: "stale",
error: None,
conflicts: None,
});
}
}
}
let ok = !any_problem;
emit_patch_files_output(global, ok, &results, Some(false), None)?;
if !global.json && !global.jsonl && !global.quiet && any_would_change && !any_problem {
let n = results
.iter()
.filter(|r| r.status == "would_change")
.count();
println!("{n} file(s) would change");
}
return Ok(if any_problem {
exit::AMBIGUOUS
} else if any_would_change {
exit::CHANGES_DETECTED
} else {
exit::SUCCESS
});
}
if merge_mode && (global.check || (!global.apply && !global.confirm)) {
let check_options = ApplyHunksOptions {
on_stale: OnStale::Merge,
allow_conflicts: true,
};
let mut results = Vec::new();
let mut all_ok = true;
for pf in &patch_files {
let file_path = cwd.join(&pf.path);
let original = match load_patch_target(&file_path, &pf.path, true) {
Ok(s) => s,
Err(PatchTargetError::InvalidInput(msg)) => {
global.emit_error_json_kind(Some("invalid_input"), &msg)?;
return Ok(exit::FAILURE);
}
Err(PatchTargetError::NotFound) => {
unreachable!("merge check uses missing_as_empty")
}
Err(PatchTargetError::NotAFile(msg) | PatchTargetError::Io(msg)) => {
global.emit_error_json_kind(
Some("invalid_input"),
&format!("patch check: cannot read {}: {msg}", pf.path),
)?;
return Ok(exit::FAILURE);
}
};
match apply_patch_file(&original, &pf.hunks, check_options) {
Ok(applied) => {
if applied.status == ApplyHunksStatus::Conflict {
all_ok = false;
}
results.push(patch_file_result(&pf.path, &applied));
}
Err(msg) => {
all_ok = false;
results.push(PatchFileResult {
path: pf.path.clone(),
status: "error",
error: Some(msg),
conflicts: None,
});
}
}
}
emit_patch_files_output(global, all_ok, &results, Some(false), None)?;
let has_errors = results.iter().any(|r| r.status == "error");
let has_conflicts = results.iter().any(|r| r.status == "conflict");
return Ok(if has_errors {
exit::AMBIGUOUS
} else if has_conflicts && !apply_options.allow_conflicts {
exit::CONFLICTS
} else {
exit::CHANGES_DETECTED
});
}
let op = Operation::PatchApply {
diff: diff_text,
on_stale: apply_options.on_stale,
allow_conflicts: apply_options.allow_conflicts,
};
let (cwd, result) =
match crate::cmd::output::stage_for_write(WriteSource::Operations(vec![op]), global) {
Ok(v) => v,
Err(e) => {
let msg = e.to_string();
let (exit_code, kind) = if exit::is_conflicts(&e) || msg.contains("conflict(s)") {
(exit::CONFLICTS, "conflicts")
} else if exit::is_invalid_input(&e) {
(exit::FAILURE, "invalid_input")
} else {
(exit::AMBIGUOUS, "ambiguous")
};
let err = if kind == "ambiguous" {
let label = if merge_mode { "MERGE FAILED" } else { "STALE" };
inject_stale_label(&msg, label)
} else {
msg
};
emit_error(global, &err, kind)?;
return Ok(exit_code);
}
};
use crate::cmd::write_mode::{FinalizeCallbacks, finalize_report};
finalize_report(
global,
&cwd,
result,
true,
FinalizeCallbacks {
on_check: |g: &GlobalFlags, _has: bool, diffs: &[crate::diff::FileDiff]| {
let files = build_file_results(diffs, "would_change");
let changed = files.len();
if changed > 0 {
emit_patch_files_output(g, true, &files, Some(false), None)?;
if !(g.json || g.jsonl || g.quiet) {
println!("{changed} file(s) would change");
}
}
Ok(())
},
on_apply: |g: &GlobalFlags,
has: bool,
diffs: &[crate::diff::FileDiff],
_plain: Option<String>,
backup: Option<String>| {
let status = if has { "applied" } else { "unchanged" };
let files = build_file_results(diffs, status);
emit_patch_files_output(g, true, &files, Some(has), backup)?;
Ok(())
},
on_preview: |g: &GlobalFlags,
_has: bool,
diffs: &[crate::diff::FileDiff],
_plain: Option<String>| {
if g.json || g.jsonl {
let files = build_file_results(diffs, "would_change");
emit_patch_files_output(g, true, &files, Some(false), None)?;
} else {
print!(
"{}",
format_diff_result_colored(
&DiffResult {
diffs: diffs.to_vec()
},
g.should_color()
)
);
}
Ok(())
},
after_preview_emit: |_: &GlobalFlags| {},
after_preview_apply: |_: &GlobalFlags| {},
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::global::GlobalFlags;
use tempfile::TempDir;
#[test]
fn merge_check_reports_conflict_without_writing() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("hello.txt");
std::fs::write(&file, "line1\ncompletely different\nline3\n").unwrap();
let diff_path = tmp.path().join("stale.patch");
std::fs::write(
&diff_path,
"--- a/hello.txt\n+++ b/hello.txt\n@@ -1,3 +1,3 @@\n line1\n-old line\n+new line\n line3\n",
)
.unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.check = true;
let code = run(
PatchArgs {
action: PatchAction::Merge {
file: Some(diff_path.to_string_lossy().into_owned()),
stdin: false,
allow_conflicts: false,
},
write: Default::default(),
},
&global,
)
.unwrap();
assert_eq!(code, exit::CONFLICTS);
}
#[cfg(unix)]
#[test]
fn load_patch_target_unreadable_does_not_double_wrap() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("locked.txt");
std::fs::write(&file, "secret\n").unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_to_string(&file).is_ok() {
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let err = load_patch_target(&file, "locked.txt", false).unwrap_err();
match err {
PatchTargetError::InvalidInput(msg) => {
assert_eq!(
msg.matches("failed to read").count(),
1,
"must not double-wrap load_text_strict context: {msg}"
);
assert!(
msg.contains("locked.txt"),
"path should appear in message: {msg}"
);
assert!(
msg.contains("Permission denied")
|| msg.contains("PermissionDenied")
|| msg.contains("os error"),
"OS detail missing: {msg}"
);
}
other => panic!("expected InvalidInput, got {other:?}"),
}
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
}
#[cfg(unix)]
#[test]
fn merge_check_surfaces_io_error_for_unreadable_file() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("secret.txt");
std::fs::write(&file, "line1\nline2\nline3\n").unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_to_string(&file).is_ok() {
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let diff_path = tmp.path().join("fix.patch");
std::fs::write(
&diff_path,
"--- a/secret.txt\n+++ b/secret.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+patched\n line3\n",
)
.unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.check = true;
let result = run(
PatchArgs {
action: PatchAction::Merge {
file: Some(diff_path.to_string_lossy().into_owned()),
stdin: false,
allow_conflicts: false,
},
write: Default::default(),
},
&global,
);
let code = result.unwrap();
assert_eq!(
code,
exit::FAILURE,
"expected I/O error for unreadable file"
);
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
}
#[test]
fn merge_check_treats_not_found_as_empty() {
let tmp = TempDir::new().unwrap();
let diff_path = tmp.path().join("new.patch");
std::fs::write(
&diff_path,
"--- /dev/null\n+++ b/new_file.txt\n@@ -0,0 +1 @@\n+hello\n",
)
.unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.check = true;
let code = run(
PatchArgs {
action: PatchAction::Merge {
file: Some(diff_path.to_string_lossy().into_owned()),
stdin: false,
allow_conflicts: false,
},
write: Default::default(),
},
&global,
)
.unwrap();
assert_eq!(code, exit::CHANGES_DETECTED);
}
#[test]
fn inject_stale_label_inserts_after_separator() {
let msg = "patch apply: test.txt -- hunk 1 failed: stale context";
let result = inject_stale_label(msg, "STALE");
assert_eq!(
result,
"patch apply: test.txt -- STALE: hunk 1 failed: stale context"
);
}
#[test]
fn conflict_matching_uses_precise_marker() {
let msg_with_conflicts = "patch apply: f.txt -- 2 conflict(s) found";
let exit_code = if msg_with_conflicts.contains("conflict(s)") {
exit::CONFLICTS
} else {
exit::AMBIGUOUS
};
assert_eq!(exit_code, exit::CONFLICTS);
let msg_generic = "patch apply: f.txt -- conflicting base version";
let exit_code2 = if msg_generic.contains("conflict(s)") {
exit::CONFLICTS
} else {
exit::AMBIGUOUS
};
assert_eq!(exit_code2, exit::AMBIGUOUS);
}
#[test]
fn inject_stale_label_fallback_without_separator() {
let msg = "some other error";
let result = inject_stale_label(msg, "STALE");
assert_eq!(result, "some other error (STALE)");
}
#[test]
fn patch_apply_json_output_on_success() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("test.txt");
std::fs::write(&file, "line one\nline two\nline three\n").unwrap();
let diff_path = tmp.path().join("fix.patch");
std::fs::write(
&diff_path,
"--- a/test.txt\n+++ b/test.txt\n@@ -1,3 +1,3 @@\n line one\n-line two\n+line TWO\n line three\n",
)
.unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.apply = true;
global.json = true;
let code = run(
PatchArgs {
action: PatchAction::Apply {
file: Some(diff_path.to_string_lossy().into_owned()),
stdin: false,
on_stale: OnStaleCli::Fail,
},
write: Default::default(),
},
&global,
)
.unwrap();
assert_eq!(code, exit::SUCCESS);
let content = std::fs::read_to_string(&file).unwrap();
assert!(content.contains("line TWO"), "patch should be applied");
}
}