Add cloud tasks (#3197)
Adds a TUI for managing, applying, and creating cloud tasks
This commit is contained in:
17
codex-rs/git-apply/Cargo.toml
Normal file
17
codex-rs/git-apply/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "codex-git-apply"
|
||||
version = { workspace = true }
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "codex_git_apply"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
once_cell = "1"
|
||||
regex = "1"
|
||||
tempfile = "3"
|
||||
|
||||
698
codex-rs/git-apply/src/lib.rs
Normal file
698
codex-rs/git-apply/src/lib.rs
Normal file
@@ -0,0 +1,698 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApplyGitRequest {
|
||||
pub cwd: PathBuf,
|
||||
pub diff: String,
|
||||
pub revert: bool,
|
||||
pub preflight: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApplyGitResult {
|
||||
pub exit_code: i32,
|
||||
pub applied_paths: Vec<String>,
|
||||
pub skipped_paths: Vec<String>,
|
||||
pub conflicted_paths: Vec<String>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub cmd_for_log: String,
|
||||
}
|
||||
|
||||
pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||||
let git_root = resolve_git_root(&req.cwd)?;
|
||||
|
||||
// Write unified diff into a temporary file
|
||||
let (tmpdir, patch_path) = write_temp_patch(&req.diff)?;
|
||||
// Keep tmpdir alive until function end to ensure the file exists
|
||||
let _guard = tmpdir;
|
||||
|
||||
if req.revert && !req.preflight {
|
||||
// Stage WT paths first to avoid index mismatch on revert.
|
||||
stage_paths(&git_root, &req.diff)?;
|
||||
}
|
||||
|
||||
// Build git args
|
||||
let mut args: Vec<String> = vec!["apply".into(), "--3way".into()];
|
||||
if req.revert {
|
||||
args.push("-R".into());
|
||||
}
|
||||
|
||||
// Optional: additional git config via env knob (defaults OFF)
|
||||
let mut cfg_parts: Vec<String> = Vec::new();
|
||||
if let Ok(cfg) = std::env::var("CODEX_APPLY_GIT_CFG") {
|
||||
for pair in cfg.split(',') {
|
||||
let p = pair.trim();
|
||||
if p.is_empty() || !p.contains('=') {
|
||||
continue;
|
||||
}
|
||||
cfg_parts.push("-c".into());
|
||||
cfg_parts.push(p.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
args.push(patch_path.to_string_lossy().to_string());
|
||||
|
||||
// Optional preflight: dry-run only; do not modify working tree
|
||||
if req.preflight {
|
||||
let mut check_args = vec!["apply".to_string(), "--check".to_string()];
|
||||
if req.revert {
|
||||
check_args.push("-R".to_string());
|
||||
}
|
||||
check_args.push(patch_path.to_string_lossy().to_string());
|
||||
let rendered = render_command_for_log(&git_root, &cfg_parts, &check_args);
|
||||
let (c_code, c_out, c_err) = run_git(&git_root, &cfg_parts, &check_args)?;
|
||||
let (mut applied_paths, mut skipped_paths, mut conflicted_paths) =
|
||||
parse_git_apply_output(&c_out, &c_err);
|
||||
applied_paths.sort();
|
||||
applied_paths.dedup();
|
||||
skipped_paths.sort();
|
||||
skipped_paths.dedup();
|
||||
conflicted_paths.sort();
|
||||
conflicted_paths.dedup();
|
||||
return Ok(ApplyGitResult {
|
||||
exit_code: c_code,
|
||||
applied_paths,
|
||||
skipped_paths,
|
||||
conflicted_paths,
|
||||
stdout: c_out,
|
||||
stderr: c_err,
|
||||
cmd_for_log: rendered,
|
||||
});
|
||||
}
|
||||
|
||||
let cmd_for_log = render_command_for_log(&git_root, &cfg_parts, &args);
|
||||
let (code, stdout, stderr) = run_git(&git_root, &cfg_parts, &args)?;
|
||||
|
||||
let (mut applied_paths, mut skipped_paths, mut conflicted_paths) =
|
||||
parse_git_apply_output(&stdout, &stderr);
|
||||
applied_paths.sort();
|
||||
applied_paths.dedup();
|
||||
skipped_paths.sort();
|
||||
skipped_paths.dedup();
|
||||
conflicted_paths.sort();
|
||||
conflicted_paths.dedup();
|
||||
|
||||
Ok(ApplyGitResult {
|
||||
exit_code: code,
|
||||
applied_paths,
|
||||
skipped_paths,
|
||||
conflicted_paths,
|
||||
stdout,
|
||||
stderr,
|
||||
cmd_for_log,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_git_root(cwd: &Path) -> io::Result<PathBuf> {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("rev-parse")
|
||||
.arg("--show-toplevel")
|
||||
.current_dir(cwd)
|
||||
.output()?;
|
||||
let code = out.status.code().unwrap_or(-1);
|
||||
if code != 0 {
|
||||
return Err(io::Error::other(format!(
|
||||
"not a git repository (exit {}): {}",
|
||||
code,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
)));
|
||||
}
|
||||
let root = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
Ok(PathBuf::from(root))
|
||||
}
|
||||
|
||||
fn write_temp_patch(diff: &str) -> io::Result<(tempfile::TempDir, PathBuf)> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("patch.diff");
|
||||
std::fs::write(&path, diff)?;
|
||||
Ok((dir, path))
|
||||
}
|
||||
|
||||
fn run_git(cwd: &Path, git_cfg: &[String], args: &[String]) -> io::Result<(i32, String, String)> {
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
for p in git_cfg {
|
||||
cmd.arg(p);
|
||||
}
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
let out = cmd.current_dir(cwd).output()?;
|
||||
let code = out.status.code().unwrap_or(-1);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
Ok((code, stdout, stderr))
|
||||
}
|
||||
|
||||
fn quote_shell(s: &str) -> String {
|
||||
let simple = s
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "-_.:/@%+".contains(c));
|
||||
if simple {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_command_for_log(cwd: &Path, git_cfg: &[String], args: &[String]) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
parts.push("git".to_string());
|
||||
for a in git_cfg {
|
||||
parts.push(quote_shell(a));
|
||||
}
|
||||
for a in args {
|
||||
parts.push(quote_shell(a));
|
||||
}
|
||||
format!(
|
||||
"(cd {} && {})",
|
||||
quote_shell(&cwd.display().to_string()),
|
||||
parts.join(" ")
|
||||
)
|
||||
}
|
||||
|
||||
pub fn extract_paths_from_patch(diff_text: &str) -> Vec<String> {
|
||||
static RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"(?m)^diff --git a/(.*?) b/(.*)$")
|
||||
.unwrap_or_else(|e| panic!("invalid regex: {e}"))
|
||||
});
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
for caps in RE.captures_iter(diff_text) {
|
||||
if let Some(a) = caps.get(1).map(|m| m.as_str())
|
||||
&& a != "/dev/null"
|
||||
&& !a.trim().is_empty()
|
||||
{
|
||||
set.insert(a.to_string());
|
||||
}
|
||||
if let Some(b) = caps.get(2).map(|m| m.as_str())
|
||||
&& b != "/dev/null"
|
||||
&& !b.trim().is_empty()
|
||||
{
|
||||
set.insert(b.to_string());
|
||||
}
|
||||
}
|
||||
set.into_iter().collect()
|
||||
}
|
||||
|
||||
pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
|
||||
let paths = extract_paths_from_patch(diff);
|
||||
let mut existing: Vec<String> = Vec::new();
|
||||
for p in paths {
|
||||
let joined = git_root.join(&p);
|
||||
if std::fs::symlink_metadata(&joined).is_ok() {
|
||||
existing.push(p);
|
||||
}
|
||||
}
|
||||
if existing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.arg("add");
|
||||
cmd.arg("--");
|
||||
for p in &existing {
|
||||
cmd.arg(OsStr::new(p));
|
||||
}
|
||||
let out = cmd.current_dir(git_root).output()?;
|
||||
let _code = out.status.code().unwrap_or(-1);
|
||||
// We do not hard fail staging; best-effort is OK. Return Ok even on non-zero.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Parser ported from VS Code (TS) ============
|
||||
|
||||
pub fn parse_git_apply_output(
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||
let combined = [stdout, stderr]
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<&str>>()
|
||||
.join("\n");
|
||||
|
||||
let mut applied = std::collections::BTreeSet::new();
|
||||
let mut skipped = std::collections::BTreeSet::new();
|
||||
let mut conflicted = std::collections::BTreeSet::new();
|
||||
let mut last_seen_path: Option<String> = None;
|
||||
|
||||
fn add(set: &mut std::collections::BTreeSet<String>, raw: &str) {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let first = trimmed.chars().next().unwrap_or('\0');
|
||||
let last = trimmed.chars().last().unwrap_or('\0');
|
||||
let unquoted = if (first == '"' || first == '\'') && last == first && trimmed.len() >= 2 {
|
||||
&trimmed[1..trimmed.len() - 1]
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
if !unquoted.is_empty() {
|
||||
set.insert(unquoted.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
static APPLIED_CLEAN: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+cleanly\\.?$"));
|
||||
static APPLIED_CONFLICTS: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+with conflicts\\.?$"));
|
||||
static APPLYING_WITH_REJECTS: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^Applying patch\\s+(?P<path>.+?)\\s+with\\s+\\d+\\s+rejects?\\.{0,3}$")
|
||||
});
|
||||
static CHECKING_PATCH: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Checking patch\\s+(?P<path>.+?)\\.\\.\\.$"));
|
||||
static UNMERGED_LINE: Lazy<Regex> = Lazy::new(|| regex_ci("^U\\s+(?P<path>.+)$"));
|
||||
static PATCH_FAILED: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)(?::\\d+)?(?:\\s|$)"));
|
||||
static DOES_NOT_APPLY: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+patch does not apply$"));
|
||||
static THREE_WAY_START: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^(?:Performing three-way merge|Falling back to three-way merge)\\.\\.\\.$")
|
||||
});
|
||||
static THREE_WAY_FAILED: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Failed to perform three-way merge\\.\\.\\.$"));
|
||||
static FALLBACK_DIRECT: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Falling back to direct application\\.\\.\\.$"));
|
||||
static LACKS_BLOB: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci(
|
||||
"^(?:error: )?repository lacks the necessary blob to (?:perform|fall back on) 3-?way merge\\.?$",
|
||||
)
|
||||
});
|
||||
static INDEX_MISMATCH: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not match index\\b"));
|
||||
static NOT_IN_INDEX: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not exist in index\\b"));
|
||||
static ALREADY_EXISTS_WT: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^error:\\s+(?P<path>.+?)\\s+already exists in (?:the )?working directory\\b")
|
||||
});
|
||||
static FILE_EXISTS: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)\\s+File exists"));
|
||||
static RENAMED_DELETED: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+path\\s+(?P<path>.+?)\\s+has been renamed\\/deleted"));
|
||||
static CANNOT_APPLY_BINARY: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci(
|
||||
"^error:\\s+cannot apply binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+without full index line$",
|
||||
)
|
||||
});
|
||||
static BINARY_DOES_NOT_APPLY: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^error:\\s+binary patch does not apply to\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
|
||||
});
|
||||
static BINARY_INCORRECT_RESULT: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci(
|
||||
"^error:\\s+binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+creates incorrect result\\b",
|
||||
)
|
||||
});
|
||||
static CANNOT_READ_CURRENT: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^error:\\s+cannot read the current contents of\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
|
||||
});
|
||||
static SKIPPED_PATCH: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Skipped patch\\s+['\\\"]?(?P<path>.+?)['\\\"]\\.$"));
|
||||
static CANNOT_MERGE_BINARY_WARN: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci(
|
||||
"^warning:\\s*Cannot merge binary files:\\s+(?P<path>.+?)\\s+\\(ours\\s+vs\\.\\s+theirs\\)",
|
||||
)
|
||||
});
|
||||
|
||||
for raw_line in combined.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// === "Checking patch <path>..." tracking ===
|
||||
if let Some(c) = CHECKING_PATCH.captures(line) {
|
||||
if let Some(m) = c.name("path") {
|
||||
last_seen_path = Some(m.as_str().to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// === Status lines ===
|
||||
if let Some(c) = APPLIED_CLEAN.captures(line) {
|
||||
if let Some(m) = c.name("path") {
|
||||
add(&mut applied, m.as_str());
|
||||
let p = applied.iter().next_back().cloned();
|
||||
if let Some(p) = p {
|
||||
conflicted.remove(&p);
|
||||
skipped.remove(&p);
|
||||
last_seen_path = Some(p);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = APPLIED_CONFLICTS.captures(line) {
|
||||
if let Some(m) = c.name("path") {
|
||||
add(&mut conflicted, m.as_str());
|
||||
let p = conflicted.iter().next_back().cloned();
|
||||
if let Some(p) = p {
|
||||
applied.remove(&p);
|
||||
skipped.remove(&p);
|
||||
last_seen_path = Some(p);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = APPLYING_WITH_REJECTS.captures(line) {
|
||||
if let Some(m) = c.name("path") {
|
||||
add(&mut conflicted, m.as_str());
|
||||
let p = conflicted.iter().next_back().cloned();
|
||||
if let Some(p) = p {
|
||||
applied.remove(&p);
|
||||
skipped.remove(&p);
|
||||
last_seen_path = Some(p);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// === “U <path>” after conflicts ===
|
||||
if let Some(c) = UNMERGED_LINE.captures(line) {
|
||||
if let Some(m) = c.name("path") {
|
||||
add(&mut conflicted, m.as_str());
|
||||
let p = conflicted.iter().next_back().cloned();
|
||||
if let Some(p) = p {
|
||||
applied.remove(&p);
|
||||
skipped.remove(&p);
|
||||
last_seen_path = Some(p);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// === Early hints ===
|
||||
if PATCH_FAILED.is_match(line) || DOES_NOT_APPLY.is_match(line) {
|
||||
if let Some(c) = PATCH_FAILED
|
||||
.captures(line)
|
||||
.or_else(|| DOES_NOT_APPLY.captures(line))
|
||||
&& let Some(m) = c.name("path")
|
||||
{
|
||||
add(&mut skipped, m.as_str());
|
||||
last_seen_path = Some(m.as_str().to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// === Ignore narration ===
|
||||
if THREE_WAY_START.is_match(line) || FALLBACK_DIRECT.is_match(line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// === 3-way failed entirely; attribute to last_seen_path ===
|
||||
if THREE_WAY_FAILED.is_match(line) || LACKS_BLOB.is_match(line) {
|
||||
if let Some(p) = last_seen_path.clone() {
|
||||
add(&mut skipped, &p);
|
||||
applied.remove(&p);
|
||||
conflicted.remove(&p);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// === Skips / I/O problems ===
|
||||
if let Some(c) = INDEX_MISMATCH
|
||||
.captures(line)
|
||||
.or_else(|| NOT_IN_INDEX.captures(line))
|
||||
.or_else(|| ALREADY_EXISTS_WT.captures(line))
|
||||
.or_else(|| FILE_EXISTS.captures(line))
|
||||
.or_else(|| RENAMED_DELETED.captures(line))
|
||||
.or_else(|| CANNOT_APPLY_BINARY.captures(line))
|
||||
.or_else(|| BINARY_DOES_NOT_APPLY.captures(line))
|
||||
.or_else(|| BINARY_INCORRECT_RESULT.captures(line))
|
||||
.or_else(|| CANNOT_READ_CURRENT.captures(line))
|
||||
.or_else(|| SKIPPED_PATCH.captures(line))
|
||||
{
|
||||
if let Some(m) = c.name("path") {
|
||||
add(&mut skipped, m.as_str());
|
||||
let p_now = skipped.iter().next_back().cloned();
|
||||
if let Some(p) = p_now {
|
||||
applied.remove(&p);
|
||||
conflicted.remove(&p);
|
||||
last_seen_path = Some(p);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// === Warnings that imply conflicts ===
|
||||
if let Some(c) = CANNOT_MERGE_BINARY_WARN.captures(line) {
|
||||
if let Some(m) = c.name("path") {
|
||||
add(&mut conflicted, m.as_str());
|
||||
let p = conflicted.iter().next_back().cloned();
|
||||
if let Some(p) = p {
|
||||
applied.remove(&p);
|
||||
skipped.remove(&p);
|
||||
last_seen_path = Some(p);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Final precedence: conflicts > applied > skipped
|
||||
for p in conflicted.iter() {
|
||||
applied.remove(p);
|
||||
skipped.remove(p);
|
||||
}
|
||||
for p in applied.iter() {
|
||||
skipped.remove(p);
|
||||
}
|
||||
|
||||
(
|
||||
applied.into_iter().collect(),
|
||||
skipped.into_iter().collect(),
|
||||
conflicted.into_iter().collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn regex_ci(pat: &str) -> Regex {
|
||||
Regex::new(&format!("(?i){pat}")).unwrap_or_else(|e| panic!("invalid regex: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) {
|
||||
let out = std::process::Command::new(args[0])
|
||||
.args(&args[1..])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("spawn ok");
|
||||
(
|
||||
out.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
fn init_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let root = dir.path();
|
||||
// git init and minimal identity
|
||||
let _ = run(root, &["git", "init"]);
|
||||
let _ = run(root, &["git", "config", "user.email", "codex@example.com"]);
|
||||
let _ = run(root, &["git", "config", "user.name", "Codex"]);
|
||||
dir
|
||||
}
|
||||
|
||||
fn read_file_normalized(path: &Path) -> String {
|
||||
std::fs::read_to_string(path)
|
||||
.expect("read file")
|
||||
.replace("\r\n", "\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_add_success() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let diff = "diff --git a/hello.txt b/hello.txt\nnew file mode 100644\n--- /dev/null\n+++ b/hello.txt\n@@ -0,0 +1,2 @@\n+hello\n+world\n";
|
||||
let req = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let r = apply_git_patch(&req).expect("run apply");
|
||||
assert_eq!(r.exit_code, 0, "exit code 0");
|
||||
// File exists now
|
||||
assert!(root.join("hello.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_modify_conflict() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
// seed file and commit
|
||||
std::fs::write(root.join("file.txt"), "line1\nline2\nline3\n").unwrap();
|
||||
let _ = run(root, &["git", "add", "file.txt"]);
|
||||
let _ = run(root, &["git", "commit", "-m", "seed"]);
|
||||
// local edit (unstaged)
|
||||
std::fs::write(root.join("file.txt"), "line1\nlocal2\nline3\n").unwrap();
|
||||
// patch wants to change the same line differently
|
||||
let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+remote2\n line3\n";
|
||||
let req = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let r = apply_git_patch(&req).expect("run apply");
|
||||
assert_ne!(r.exit_code, 0, "non-zero exit on conflict");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_modify_skipped_missing_index() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
// Try to modify a file that is not in the index
|
||||
let diff = "diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
|
||||
let req = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let r = apply_git_patch(&req).expect("run apply");
|
||||
assert_ne!(r.exit_code, 0, "non-zero exit on missing index");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_then_revert_success() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
// Seed file and commit original content
|
||||
std::fs::write(root.join("file.txt"), "orig\n").unwrap();
|
||||
let _ = run(root, &["git", "add", "file.txt"]);
|
||||
let _ = run(root, &["git", "commit", "-m", "seed"]);
|
||||
|
||||
// Forward patch: orig -> ORIG
|
||||
let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n";
|
||||
let apply_req = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let res_apply = apply_git_patch(&apply_req).expect("apply ok");
|
||||
assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
|
||||
let after_apply = read_file_normalized(&root.join("file.txt"));
|
||||
assert_eq!(after_apply, "ORIG\n");
|
||||
|
||||
// Revert patch: ORIG -> orig (stage paths first; engine handles it)
|
||||
let revert_req = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: true,
|
||||
preflight: false,
|
||||
};
|
||||
let res_revert = apply_git_patch(&revert_req).expect("revert ok");
|
||||
assert_eq!(res_revert.exit_code, 0, "revert apply succeeded");
|
||||
let after_revert = read_file_normalized(&root.join("file.txt"));
|
||||
assert_eq!(after_revert, "orig\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_preflight_does_not_stage_index() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
// Seed repo and apply forward patch so the working tree reflects the change.
|
||||
std::fs::write(root.join("file.txt"), "orig\n").unwrap();
|
||||
let _ = run(root, &["git", "add", "file.txt"]);
|
||||
let _ = run(root, &["git", "commit", "-m", "seed"]);
|
||||
|
||||
let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n";
|
||||
let apply_req = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let res_apply = apply_git_patch(&apply_req).expect("apply ok");
|
||||
assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
|
||||
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-am", "apply change"]);
|
||||
assert_eq!(commit_code, 0, "commit applied change: {commit_err}");
|
||||
|
||||
let (_code_before, staged_before, _stderr_before) =
|
||||
run(root, &["git", "diff", "--cached", "--name-only"]);
|
||||
|
||||
let preflight_req = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: true,
|
||||
preflight: true,
|
||||
};
|
||||
let res_preflight = apply_git_patch(&preflight_req).expect("preflight ok");
|
||||
assert_eq!(res_preflight.exit_code, 0, "revert preflight succeeded");
|
||||
let (_code_after, staged_after, _stderr_after) =
|
||||
run(root, &["git", "diff", "--cached", "--name-only"]);
|
||||
assert_eq!(
|
||||
staged_after.trim(),
|
||||
staged_before.trim(),
|
||||
"preflight should not stage new paths",
|
||||
);
|
||||
|
||||
let after_preflight = read_file_normalized(&root.join("file.txt"));
|
||||
assert_eq!(after_preflight, "ORIG\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_blocks_partial_changes() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
// Build a multi-file diff: one valid add (ok.txt) and one invalid modify (ghost.txt)
|
||||
let diff = "diff --git a/ok.txt b/ok.txt\nnew file mode 100644\n--- /dev/null\n+++ b/ok.txt\n@@ -0,0 +1,2 @@\n+alpha\n+beta\n\n\
|
||||
diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
|
||||
|
||||
// 1) With preflight enabled, nothing should be changed (even though ok.txt could be added)
|
||||
let req1 = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: true,
|
||||
};
|
||||
let r1 = apply_git_patch(&req1).expect("preflight apply");
|
||||
assert_ne!(r1.exit_code, 0, "preflight reports failure");
|
||||
assert!(
|
||||
!root.join("ok.txt").exists(),
|
||||
"preflight must prevent adding ok.txt"
|
||||
);
|
||||
assert!(
|
||||
r1.cmd_for_log.contains("--check"),
|
||||
"preflight path recorded --check"
|
||||
);
|
||||
|
||||
// 2) Without preflight, we should see no --check in the executed command
|
||||
let req2 = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
let r2 = apply_git_patch(&req2).expect("direct apply");
|
||||
assert_ne!(r2.exit_code, 0, "apply is expected to fail overall");
|
||||
assert!(
|
||||
!r2.cmd_for_log.contains("--check"),
|
||||
"non-preflight path should not use --check"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user