mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Migrate apply_patch to executor filesystem (#17027)
- Migrate apply-patch verification and application internals to use the async `ExecutorFileSystem` abstraction from `exec-server`. - Convert apply-patch `cwd` handling to `AbsolutePathBuf` through the verifier/parser/handler boundary. Doesn't change how the tool itself works.
This commit is contained in:
@@ -2,6 +2,8 @@ use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use tree_sitter::Parser;
|
||||
use tree_sitter::Query;
|
||||
use tree_sitter::QueryCursor;
|
||||
@@ -129,7 +131,11 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
|
||||
|
||||
/// cwd must be an absolute path so that we can resolve relative paths in the
|
||||
/// patch.
|
||||
pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified {
|
||||
pub async fn maybe_parse_apply_patch_verified(
|
||||
argv: &[String],
|
||||
cwd: &AbsolutePathBuf,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> MaybeApplyPatchVerified {
|
||||
// Detect a raw patch body passed directly as the command or as the body of a shell
|
||||
// script. In these cases, report an explicit error rather than applying the patch.
|
||||
if let [body] = argv
|
||||
@@ -151,24 +157,20 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
|
||||
}) => {
|
||||
let effective_cwd = workdir
|
||||
.as_ref()
|
||||
.map(|dir| {
|
||||
let path = Path::new(dir);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
cwd.join(path)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| cwd.to_path_buf());
|
||||
.map(|dir| cwd.join(Path::new(dir)))
|
||||
.unwrap_or_else(|| cwd.clone());
|
||||
let mut changes = HashMap::new();
|
||||
for hunk in hunks {
|
||||
let path = hunk.resolve_path(&effective_cwd);
|
||||
match hunk {
|
||||
Hunk::AddFile { contents, .. } => {
|
||||
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
|
||||
changes.insert(
|
||||
path.into_path_buf(),
|
||||
ApplyPatchFileChange::Add { content: contents },
|
||||
);
|
||||
}
|
||||
Hunk::DeleteFile { .. } => {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
let content = match fs.read_file_text(&path).await {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(
|
||||
@@ -179,7 +181,10 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
|
||||
);
|
||||
}
|
||||
};
|
||||
changes.insert(path, ApplyPatchFileChange::Delete { content });
|
||||
changes.insert(
|
||||
path.into_path_buf(),
|
||||
ApplyPatchFileChange::Delete { content },
|
||||
);
|
||||
}
|
||||
Hunk::UpdateFile {
|
||||
move_path, chunks, ..
|
||||
@@ -187,17 +192,17 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
|
||||
let ApplyPatchFileUpdate {
|
||||
unified_diff,
|
||||
content: contents,
|
||||
} = match unified_diff_from_chunks(&path, &chunks) {
|
||||
} = match unified_diff_from_chunks(&path, &chunks, fs).await {
|
||||
Ok(diff) => diff,
|
||||
Err(e) => {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(e);
|
||||
}
|
||||
};
|
||||
changes.insert(
|
||||
path,
|
||||
path.into_path_buf(),
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff,
|
||||
move_path: move_path.map(|p| effective_cwd.join(p)),
|
||||
move_path: move_path.map(|p| effective_cwd.join(p).into_path_buf()),
|
||||
new_content: contents,
|
||||
},
|
||||
);
|
||||
@@ -371,7 +376,10 @@ fn extract_apply_patch_from_bash(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::unified_diff_from_chunks;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -450,30 +458,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implicit_patch_single_arg_is_error() {
|
||||
#[tokio::test]
|
||||
async fn test_implicit_patch_single_arg_is_error() {
|
||||
let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
|
||||
let args = vec![patch];
|
||||
let dir = tempdir().unwrap();
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(&args, dir.path()),
|
||||
maybe_parse_apply_patch_verified(
|
||||
&args,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
LOCAL_FS.as_ref()
|
||||
)
|
||||
.await,
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implicit_patch_bash_script_is_error() {
|
||||
#[tokio::test]
|
||||
async fn test_implicit_patch_bash_script_is_error() {
|
||||
let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
|
||||
let args = args_bash(script);
|
||||
let dir = tempdir().unwrap();
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(&args, dir.path()),
|
||||
maybe_parse_apply_patch_verified(
|
||||
&args,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
LOCAL_FS.as_ref()
|
||||
)
|
||||
.await,
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal() {
|
||||
#[tokio::test]
|
||||
async fn test_literal() {
|
||||
let args = strs_to_strings(&[
|
||||
"apply_patch",
|
||||
r#"*** Begin Patch
|
||||
@@ -497,8 +515,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal_applypatch() {
|
||||
#[tokio::test]
|
||||
async fn test_literal_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"applypatch",
|
||||
r#"*** Begin Patch
|
||||
@@ -522,20 +540,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc() {
|
||||
#[tokio::test]
|
||||
async fn test_heredoc() {
|
||||
assert_match(&heredoc_script(""), /*expected_workdir*/ None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_non_login_shell() {
|
||||
#[tokio::test]
|
||||
async fn test_heredoc_non_login_shell() {
|
||||
let script = heredoc_script("");
|
||||
let args = strs_to_strings(&["bash", "-c", &script]);
|
||||
assert_match_args(args, /*expected_workdir*/ None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_applypatch() {
|
||||
#[tokio::test]
|
||||
async fn test_heredoc_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
@@ -562,96 +580,96 @@ PATCH"#,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_powershell_heredoc() {
|
||||
#[tokio::test]
|
||||
async fn test_powershell_heredoc() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_powershell(&script), /*expected_workdir*/ None);
|
||||
}
|
||||
#[test]
|
||||
fn test_powershell_heredoc_no_profile() {
|
||||
#[tokio::test]
|
||||
async fn test_powershell_heredoc_no_profile() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(
|
||||
args_powershell_no_profile(&script),
|
||||
/*expected_workdir*/ None,
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn test_pwsh_heredoc() {
|
||||
#[tokio::test]
|
||||
async fn test_pwsh_heredoc() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_pwsh(&script), /*expected_workdir*/ None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cmd_heredoc_with_cd() {
|
||||
#[tokio::test]
|
||||
async fn test_cmd_heredoc_with_cd() {
|
||||
let script = heredoc_script("cd foo && ");
|
||||
assert_match_args(args_cmd(&script), Some("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_with_leading_cd() {
|
||||
#[tokio::test]
|
||||
async fn test_heredoc_with_leading_cd() {
|
||||
assert_match(&heredoc_script("cd foo && "), Some("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_with_semicolon_is_ignored() {
|
||||
#[tokio::test]
|
||||
async fn test_cd_with_semicolon_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo; "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_or_apply_patch_is_ignored() {
|
||||
#[tokio::test]
|
||||
async fn test_cd_or_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd bar || "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_pipe_apply_patch_is_ignored() {
|
||||
#[tokio::test]
|
||||
async fn test_cd_pipe_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd bar | "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_single_quoted_path_with_spaces() {
|
||||
#[tokio::test]
|
||||
async fn test_cd_single_quoted_path_with_spaces() {
|
||||
assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_double_quoted_path_with_spaces() {
|
||||
#[tokio::test]
|
||||
async fn test_cd_double_quoted_path_with_spaces() {
|
||||
assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_echo_and_apply_patch_is_ignored() {
|
||||
#[tokio::test]
|
||||
async fn test_echo_and_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("echo foo && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_with_arg_is_ignored() {
|
||||
#[tokio::test]
|
||||
async fn test_apply_patch_with_arg_is_ignored() {
|
||||
let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
|
||||
assert_not_match(script);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_cd_then_apply_patch_is_ignored() {
|
||||
#[tokio::test]
|
||||
async fn test_double_cd_then_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo && cd bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_two_args_is_ignored() {
|
||||
#[tokio::test]
|
||||
async fn test_cd_two_args_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_then_apply_patch_then_extra_is_ignored() {
|
||||
#[tokio::test]
|
||||
async fn test_cd_then_apply_patch_then_extra_is_ignored() {
|
||||
let script = heredoc_script_ps("cd bar && ", " && echo done");
|
||||
assert_not_match(&script);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_echo_then_cd_and_apply_patch_is_ignored() {
|
||||
#[tokio::test]
|
||||
async fn test_echo_then_cd_and_apply_patch_is_ignored() {
|
||||
// Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match.
|
||||
assert_not_match(&heredoc_script("echo foo; cd bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff_last_line_replacement() {
|
||||
#[tokio::test]
|
||||
async fn test_unified_diff_last_line_replacement() {
|
||||
// Replace the very last line of the file.
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("last.txt");
|
||||
@@ -674,7 +692,10 @@ PATCH"#,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -2,2 +2,2 @@
|
||||
bar
|
||||
-baz
|
||||
@@ -687,8 +708,8 @@ PATCH"#,
|
||||
assert_eq!(expected, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff_insert_at_eof() {
|
||||
#[tokio::test]
|
||||
async fn test_unified_diff_insert_at_eof() {
|
||||
// Insert a new line at end‑of‑file.
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("insert.txt");
|
||||
@@ -709,7 +730,10 @@ PATCH"#,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -3 +3,2 @@
|
||||
baz
|
||||
+quux
|
||||
@@ -721,8 +745,8 @@ PATCH"#,
|
||||
assert_eq!(expected, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
|
||||
#[tokio::test]
|
||||
async fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
let relative_path = "source.txt";
|
||||
|
||||
@@ -742,7 +766,12 @@ PATCH"#,
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
|
||||
let result = maybe_parse_apply_patch_verified(
|
||||
&argv,
|
||||
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
|
||||
LOCAL_FS.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Verify the patch contents - as otherwise we may have pulled contents
|
||||
// from the wrong file (as we're using relative paths)
|
||||
@@ -762,13 +791,13 @@ PATCH"#,
|
||||
},
|
||||
)]),
|
||||
patch: argv[1].clone(),
|
||||
cwd: session_dir.path().to_path_buf(),
|
||||
cwd: AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_resolves_move_path_with_effective_cwd() {
|
||||
#[tokio::test]
|
||||
async fn test_apply_patch_resolves_move_path_with_effective_cwd() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
let worktree_rel = "alt";
|
||||
let worktree_dir = session_dir.path().join(worktree_rel);
|
||||
@@ -790,13 +819,18 @@ PATCH"#,
|
||||
let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH");
|
||||
let argv = vec!["bash".into(), "-lc".into(), shell_script];
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
|
||||
let result = maybe_parse_apply_patch_verified(
|
||||
&argv,
|
||||
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
|
||||
LOCAL_FS.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let action = match result {
|
||||
MaybeApplyPatchVerified::Body(action) => action,
|
||||
other => panic!("expected verified body, got {other:?}"),
|
||||
};
|
||||
|
||||
assert_eq!(action.cwd, worktree_dir);
|
||||
assert_eq!(action.cwd.as_path(), worktree_dir.as_path());
|
||||
|
||||
let change = action
|
||||
.changes()
|
||||
|
||||
+325
-134
@@ -4,11 +4,16 @@ mod seek_sequence;
|
||||
mod standalone_executable;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::RemoveOptions;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
pub use parser::Hunk;
|
||||
pub use parser::ParseError;
|
||||
use parser::ParseError::*;
|
||||
@@ -134,7 +139,7 @@ pub struct ApplyPatchAction {
|
||||
pub patch: String,
|
||||
|
||||
/// The working directory that was used to resolve relative paths in the patch.
|
||||
pub cwd: PathBuf,
|
||||
pub cwd: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl ApplyPatchAction {
|
||||
@@ -149,11 +154,7 @@ impl ApplyPatchAction {
|
||||
|
||||
/// Should be used exclusively for testing. (Not worth the overhead of
|
||||
/// creating a feature flag for this.)
|
||||
pub fn new_add_for_test(path: &Path, content: String) -> Self {
|
||||
if !path.is_absolute() {
|
||||
panic!("path must be absolute");
|
||||
}
|
||||
|
||||
pub fn new_add_for_test(path: &AbsolutePathBuf, content: String) -> Self {
|
||||
#[expect(clippy::expect_used)]
|
||||
let filename = path
|
||||
.file_name()
|
||||
@@ -170,20 +171,19 @@ impl ApplyPatchAction {
|
||||
#[expect(clippy::expect_used)]
|
||||
Self {
|
||||
changes,
|
||||
cwd: path
|
||||
.parent()
|
||||
.expect("path should have parent")
|
||||
.to_path_buf(),
|
||||
cwd: path.parent().expect("path should have parent"),
|
||||
patch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the patch and prints the result to stdout/stderr.
|
||||
pub fn apply_patch(
|
||||
pub async fn apply_patch(
|
||||
patch: &str,
|
||||
cwd: &AbsolutePathBuf,
|
||||
stdout: &mut impl std::io::Write,
|
||||
stderr: &mut impl std::io::Write,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> Result<(), ApplyPatchError> {
|
||||
let hunks = match parse_patch(patch) {
|
||||
Ok(source) => source.hunks,
|
||||
@@ -207,45 +207,21 @@ pub fn apply_patch(
|
||||
}
|
||||
};
|
||||
|
||||
apply_hunks(&hunks, stdout, stderr)?;
|
||||
apply_hunks(&hunks, cwd, stdout, stderr, fs).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies hunks and continues to update stdout/stderr
|
||||
pub fn apply_hunks(
|
||||
pub async fn apply_hunks(
|
||||
hunks: &[Hunk],
|
||||
cwd: &AbsolutePathBuf,
|
||||
stdout: &mut impl std::io::Write,
|
||||
stderr: &mut impl std::io::Write,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> Result<(), ApplyPatchError> {
|
||||
let _existing_paths: Vec<&Path> = hunks
|
||||
.iter()
|
||||
.filter_map(|hunk| match hunk {
|
||||
Hunk::AddFile { .. } => {
|
||||
// The file is being added, so it doesn't exist yet.
|
||||
None
|
||||
}
|
||||
Hunk::DeleteFile { path } => Some(path.as_path()),
|
||||
Hunk::UpdateFile {
|
||||
path, move_path, ..
|
||||
} => match move_path {
|
||||
Some(move_path) => {
|
||||
if std::fs::metadata(move_path)
|
||||
.map(|m| m.is_file())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Some(move_path.as_path())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => Some(path.as_path()),
|
||||
},
|
||||
})
|
||||
.collect::<Vec<&Path>>();
|
||||
|
||||
// Delegate to a helper that applies each hunk to the filesystem.
|
||||
match apply_hunks_to_files(hunks) {
|
||||
match apply_hunks_to_files(hunks, cwd, fs).await {
|
||||
Ok(affected) => {
|
||||
print_summary(&affected, stdout).map_err(ApplyPatchError::from)?;
|
||||
Ok(())
|
||||
@@ -267,7 +243,8 @@ pub fn apply_hunks(
|
||||
|
||||
/// Applies each parsed patch hunk to the filesystem.
|
||||
/// Returns an error if any of the changes could not be applied.
|
||||
/// Tracks file paths affected by applying a patch.
|
||||
/// Tracks file paths affected by applying a patch, preserving the path spelling
|
||||
/// from the patch for user-facing summaries.
|
||||
pub struct AffectedPaths {
|
||||
pub added: Vec<PathBuf>,
|
||||
pub modified: Vec<PathBuf>,
|
||||
@@ -276,7 +253,11 @@ pub struct AffectedPaths {
|
||||
|
||||
/// Apply the hunks to the filesystem, returning which files were added, modified, or deleted.
|
||||
/// Returns an error if the patch could not be applied.
|
||||
fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result<AffectedPaths> {
|
||||
async fn apply_hunks_to_files(
|
||||
hunks: &[Hunk],
|
||||
cwd: &AbsolutePathBuf,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> anyhow::Result<AffectedPaths> {
|
||||
if hunks.is_empty() {
|
||||
anyhow::bail!("No files were modified.");
|
||||
}
|
||||
@@ -285,48 +266,97 @@ fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result<AffectedPaths> {
|
||||
let mut modified: Vec<PathBuf> = Vec::new();
|
||||
let mut deleted: Vec<PathBuf> = Vec::new();
|
||||
for hunk in hunks {
|
||||
let affected_path = hunk.path().to_path_buf();
|
||||
let path_abs = hunk.resolve_path(cwd);
|
||||
match hunk {
|
||||
Hunk::AddFile { path, contents } => {
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("Failed to create parent directories for {}", path.display())
|
||||
})?;
|
||||
Hunk::AddFile { contents, .. } => {
|
||||
if let Some(parent_abs) = path_abs.parent() {
|
||||
fs.create_directory(&parent_abs, CreateDirectoryOptions { recursive: true })
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to create parent directories for {}",
|
||||
path_abs.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
std::fs::write(path, contents)
|
||||
.with_context(|| format!("Failed to write file {}", path.display()))?;
|
||||
added.push(path.clone());
|
||||
fs.write_file(&path_abs, contents.clone().into_bytes())
|
||||
.await
|
||||
.with_context(|| format!("Failed to write file {}", path_abs.display()))?;
|
||||
added.push(affected_path);
|
||||
}
|
||||
Hunk::DeleteFile { path } => {
|
||||
std::fs::remove_file(path)
|
||||
.with_context(|| format!("Failed to delete file {}", path.display()))?;
|
||||
deleted.push(path.clone());
|
||||
Hunk::DeleteFile { .. } => {
|
||||
let result: io::Result<()> = async {
|
||||
let metadata = fs.get_metadata(&path_abs).await?;
|
||||
if metadata.is_directory {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"path is a directory",
|
||||
));
|
||||
}
|
||||
fs.remove(
|
||||
&path_abs,
|
||||
RemoveOptions {
|
||||
recursive: false,
|
||||
force: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
result.with_context(|| format!("Failed to delete file {}", path_abs.display()))?;
|
||||
deleted.push(affected_path);
|
||||
}
|
||||
Hunk::UpdateFile {
|
||||
path,
|
||||
move_path,
|
||||
chunks,
|
||||
move_path, chunks, ..
|
||||
} => {
|
||||
let AppliedPatch { new_contents, .. } =
|
||||
derive_new_contents_from_chunks(path, chunks)?;
|
||||
derive_new_contents_from_chunks(&path_abs, chunks, fs).await?;
|
||||
if let Some(dest) = move_path {
|
||||
if let Some(parent) = dest.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("Failed to create parent directories for {}", dest.display())
|
||||
let dest_abs = AbsolutePathBuf::resolve_path_against_base(dest, cwd);
|
||||
if let Some(parent_abs) = dest_abs.parent() {
|
||||
fs.create_directory(
|
||||
&parent_abs,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to create parent directories for {}",
|
||||
dest_abs.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
std::fs::write(dest, new_contents)
|
||||
.with_context(|| format!("Failed to write file {}", dest.display()))?;
|
||||
std::fs::remove_file(path)
|
||||
.with_context(|| format!("Failed to remove original {}", path.display()))?;
|
||||
modified.push(dest.clone());
|
||||
fs.write_file(&dest_abs, new_contents.into_bytes())
|
||||
.await
|
||||
.with_context(|| format!("Failed to write file {}", dest_abs.display()))?;
|
||||
let result: io::Result<()> = async {
|
||||
let metadata = fs.get_metadata(&path_abs).await?;
|
||||
if metadata.is_directory {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"path is a directory",
|
||||
));
|
||||
}
|
||||
fs.remove(
|
||||
&path_abs,
|
||||
RemoveOptions {
|
||||
recursive: false,
|
||||
force: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
result.with_context(|| {
|
||||
format!("Failed to remove original {}", path_abs.display())
|
||||
})?;
|
||||
modified.push(affected_path);
|
||||
} else {
|
||||
std::fs::write(path, new_contents)
|
||||
.with_context(|| format!("Failed to write file {}", path.display()))?;
|
||||
modified.push(path.clone());
|
||||
fs.write_file(&path_abs, new_contents.into_bytes())
|
||||
.await
|
||||
.with_context(|| format!("Failed to write file {}", path_abs.display()))?;
|
||||
modified.push(affected_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,19 +375,17 @@ struct AppliedPatch {
|
||||
|
||||
/// Return *only* the new file contents (joined into a single `String`) after
|
||||
/// applying the chunks to the file at `path`.
|
||||
fn derive_new_contents_from_chunks(
|
||||
path: &Path,
|
||||
async fn derive_new_contents_from_chunks(
|
||||
path_abs: &AbsolutePathBuf,
|
||||
chunks: &[UpdateFileChunk],
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> std::result::Result<AppliedPatch, ApplyPatchError> {
|
||||
let original_contents = match std::fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => {
|
||||
return Err(ApplyPatchError::IoError(IoError {
|
||||
context: format!("Failed to read file to update {}", path.display()),
|
||||
source: err,
|
||||
}));
|
||||
}
|
||||
};
|
||||
let original_contents = fs.read_file_text(path_abs).await.map_err(|err| {
|
||||
ApplyPatchError::IoError(IoError {
|
||||
context: format!("Failed to read file to update {}", path_abs.display()),
|
||||
source: err,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut original_lines: Vec<String> = original_contents.split('\n').map(String::from).collect();
|
||||
|
||||
@@ -367,7 +395,7 @@ fn derive_new_contents_from_chunks(
|
||||
original_lines.pop();
|
||||
}
|
||||
|
||||
let replacements = compute_replacements(&original_lines, path, chunks)?;
|
||||
let replacements = compute_replacements(&original_lines, path_abs.as_path(), chunks)?;
|
||||
let new_lines = apply_replacements(original_lines, &replacements);
|
||||
let mut new_lines = new_lines;
|
||||
if !new_lines.last().is_some_and(String::is_empty) {
|
||||
@@ -508,22 +536,24 @@ pub struct ApplyPatchFileUpdate {
|
||||
content: String,
|
||||
}
|
||||
|
||||
pub fn unified_diff_from_chunks(
|
||||
path: &Path,
|
||||
pub async fn unified_diff_from_chunks(
|
||||
path_abs: &AbsolutePathBuf,
|
||||
chunks: &[UpdateFileChunk],
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
|
||||
unified_diff_from_chunks_with_context(path, chunks, /*context*/ 1)
|
||||
unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs).await
|
||||
}
|
||||
|
||||
pub fn unified_diff_from_chunks_with_context(
|
||||
path: &Path,
|
||||
pub async fn unified_diff_from_chunks_with_context(
|
||||
path_abs: &AbsolutePathBuf,
|
||||
chunks: &[UpdateFileChunk],
|
||||
context: usize,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
|
||||
let AppliedPatch {
|
||||
original_contents,
|
||||
new_contents,
|
||||
} = derive_new_contents_from_chunks(path, chunks)?;
|
||||
} = derive_new_contents_from_chunks(path_abs, chunks, fs).await?;
|
||||
let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
|
||||
let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
|
||||
Ok(ApplyPatchFileUpdate {
|
||||
@@ -554,6 +584,8 @@ pub fn print_summary(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::string::ToString;
|
||||
@@ -564,8 +596,8 @@ mod tests {
|
||||
format!("*** Begin Patch\n{body}\n*** End Patch")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_file_hunk_creates_file_with_contents() {
|
||||
#[tokio::test]
|
||||
async fn test_add_file_hunk_creates_file_with_contents() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("add.txt");
|
||||
let patch = wrap_patch(&format!(
|
||||
@@ -576,7 +608,15 @@ mod tests {
|
||||
));
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Verify expected stdout and stderr outputs.
|
||||
let stdout_str = String::from_utf8(stdout).unwrap();
|
||||
let stderr_str = String::from_utf8(stderr).unwrap();
|
||||
@@ -590,15 +630,88 @@ mod tests {
|
||||
assert_eq!(contents, "ab\ncd\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_file_hunk_removes_file() {
|
||||
#[tokio::test]
|
||||
async fn test_apply_patch_hunks_accept_relative_and_absolute_paths() {
|
||||
let dir = tempdir().unwrap();
|
||||
let cwd = dir.path().abs();
|
||||
let relative_add = dir.path().join("relative-add.txt");
|
||||
let absolute_add = dir.path().join("absolute-add.txt");
|
||||
let relative_delete = dir.path().join("relative-delete.txt");
|
||||
let absolute_delete = dir.path().join("absolute-delete.txt");
|
||||
let relative_update = dir.path().join("relative-update.txt");
|
||||
let absolute_update = dir.path().join("absolute-update.txt");
|
||||
fs::write(&relative_delete, "delete relative\n").unwrap();
|
||||
fs::write(&absolute_delete, "delete absolute\n").unwrap();
|
||||
fs::write(&relative_update, "relative old\n").unwrap();
|
||||
fs::write(&absolute_update, "absolute old\n").unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Add File: relative-add.txt
|
||||
+relative add
|
||||
*** Add File: {}
|
||||
+absolute add
|
||||
*** Delete File: relative-delete.txt
|
||||
*** Delete File: {}
|
||||
*** Update File: relative-update.txt
|
||||
@@
|
||||
-relative old
|
||||
+relative new
|
||||
*** Update File: {}
|
||||
@@
|
||||
-absolute old
|
||||
+absolute new"#,
|
||||
absolute_add.display(),
|
||||
absolute_delete.display(),
|
||||
absolute_update.display(),
|
||||
));
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
|
||||
apply_patch(&patch, &cwd, &mut stdout, &mut stderr, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&relative_add).unwrap(), "relative add\n");
|
||||
assert_eq!(fs::read_to_string(&absolute_add).unwrap(), "absolute add\n");
|
||||
assert!(!relative_delete.exists());
|
||||
assert!(!absolute_delete.exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(&relative_update).unwrap(),
|
||||
"relative new\n"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&absolute_update).unwrap(),
|
||||
"absolute new\n"
|
||||
);
|
||||
assert_eq!(String::from_utf8(stderr).unwrap(), "");
|
||||
assert_eq!(
|
||||
String::from_utf8(stdout).unwrap(),
|
||||
format!(
|
||||
"Success. Updated the following files:\nA relative-add.txt\nA {}\nM relative-update.txt\nM {}\nD relative-delete.txt\nD {}\n",
|
||||
absolute_add.display(),
|
||||
absolute_update.display(),
|
||||
absolute_delete.display(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_file_hunk_removes_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("del.txt");
|
||||
fs::write(&path, "x").unwrap();
|
||||
let patch = wrap_patch(&format!("*** Delete File: {}", path.display()));
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let stdout_str = String::from_utf8(stdout).unwrap();
|
||||
let stderr_str = String::from_utf8(stderr).unwrap();
|
||||
let expected_out = format!(
|
||||
@@ -610,8 +723,8 @@ mod tests {
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_file_hunk_modifies_content() {
|
||||
#[tokio::test]
|
||||
async fn test_update_file_hunk_modifies_content() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("update.txt");
|
||||
fs::write(&path, "foo\nbar\n").unwrap();
|
||||
@@ -625,7 +738,15 @@ mod tests {
|
||||
));
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Validate modified file contents and expected stdout/stderr.
|
||||
let stdout_str = String::from_utf8(stdout).unwrap();
|
||||
let stderr_str = String::from_utf8(stderr).unwrap();
|
||||
@@ -639,8 +760,8 @@ mod tests {
|
||||
assert_eq!(contents, "foo\nbaz\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_file_hunk_can_move_file() {
|
||||
#[tokio::test]
|
||||
async fn test_update_file_hunk_can_move_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let src = dir.path().join("src.txt");
|
||||
let dest = dir.path().join("dst.txt");
|
||||
@@ -656,7 +777,15 @@ mod tests {
|
||||
));
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Validate move semantics and expected stdout/stderr.
|
||||
let stdout_str = String::from_utf8(stdout).unwrap();
|
||||
let stderr_str = String::from_utf8(stderr).unwrap();
|
||||
@@ -673,8 +802,8 @@ mod tests {
|
||||
|
||||
/// Verify that a single `Update File` hunk with multiple change chunks can update different
|
||||
/// parts of a file and that the file is listed only once in the summary.
|
||||
#[test]
|
||||
fn test_multiple_update_chunks_apply_to_single_file() {
|
||||
#[tokio::test]
|
||||
async fn test_multiple_update_chunks_apply_to_single_file() {
|
||||
// Start with a file containing four lines.
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("multi.txt");
|
||||
@@ -696,7 +825,15 @@ mod tests {
|
||||
));
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let stdout_str = String::from_utf8(stdout).unwrap();
|
||||
let stderr_str = String::from_utf8(stderr).unwrap();
|
||||
let expected_out = format!(
|
||||
@@ -713,8 +850,8 @@ mod tests {
|
||||
/// replacements in separate chunks that appear in non‑adjacent parts of the
|
||||
/// file. Verifies that all edits are applied and that the summary lists the
|
||||
/// file only once.
|
||||
#[test]
|
||||
fn test_update_file_hunk_interleaved_changes() {
|
||||
#[tokio::test]
|
||||
async fn test_update_file_hunk_interleaved_changes() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("interleaved.txt");
|
||||
|
||||
@@ -745,7 +882,15 @@ mod tests {
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stdout_str = String::from_utf8(stdout).unwrap();
|
||||
let stderr_str = String::from_utf8(stderr).unwrap();
|
||||
@@ -761,8 +906,8 @@ mod tests {
|
||||
assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pure_addition_chunk_followed_by_removal() {
|
||||
#[tokio::test]
|
||||
async fn test_pure_addition_chunk_followed_by_removal() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("panic.txt");
|
||||
fs::write(&path, "line1\nline2\nline3\n").unwrap();
|
||||
@@ -780,7 +925,15 @@ mod tests {
|
||||
));
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let contents = fs::read_to_string(path).unwrap();
|
||||
assert_eq!(
|
||||
contents,
|
||||
@@ -794,8 +947,8 @@ mod tests {
|
||||
/// internal matcher failed requiring an exact byte-for-byte match. The
|
||||
/// fuzzy-matching pass that normalises common punctuation should now bridge
|
||||
/// the gap.
|
||||
#[test]
|
||||
fn test_update_line_with_unicode_dash() {
|
||||
#[tokio::test]
|
||||
async fn test_update_line_with_unicode_dash() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("unicode.py");
|
||||
|
||||
@@ -814,7 +967,15 @@ mod tests {
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// File should now contain the replaced comment.
|
||||
let expected = "import asyncio # HELLO\n";
|
||||
@@ -833,8 +994,8 @@ mod tests {
|
||||
assert_eq!(String::from_utf8(stderr).unwrap(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff() {
|
||||
#[tokio::test]
|
||||
async fn test_unified_diff() {
|
||||
// Start with a file containing four lines.
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("multi.txt");
|
||||
@@ -857,7 +1018,10 @@ mod tests {
|
||||
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
let diff = unified_diff_from_chunks(&path, update_file_chunks).unwrap();
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, update_file_chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -1,4 +1,4 @@
|
||||
foo
|
||||
-bar
|
||||
@@ -873,8 +1037,8 @@ mod tests {
|
||||
assert_eq!(expected, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff_first_line_replacement() {
|
||||
#[tokio::test]
|
||||
async fn test_unified_diff_first_line_replacement() {
|
||||
// Replace the very first line of the file.
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("first.txt");
|
||||
@@ -896,7 +1060,10 @@ mod tests {
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -1,2 +1,2 @@
|
||||
-foo
|
||||
+FOO
|
||||
@@ -909,8 +1076,8 @@ mod tests {
|
||||
assert_eq!(expected, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff_last_line_replacement() {
|
||||
#[tokio::test]
|
||||
async fn test_unified_diff_last_line_replacement() {
|
||||
// Replace the very last line of the file.
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("last.txt");
|
||||
@@ -933,7 +1100,10 @@ mod tests {
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -2,2 +2,2 @@
|
||||
bar
|
||||
-baz
|
||||
@@ -946,8 +1116,8 @@ mod tests {
|
||||
assert_eq!(expected, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff_insert_at_eof() {
|
||||
#[tokio::test]
|
||||
async fn test_unified_diff_insert_at_eof() {
|
||||
// Insert a new line at end‑of‑file.
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("insert.txt");
|
||||
@@ -968,7 +1138,10 @@ mod tests {
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -3 +3,2 @@
|
||||
baz
|
||||
+quux
|
||||
@@ -980,8 +1153,8 @@ mod tests {
|
||||
assert_eq!(expected, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff_interleaved_changes() {
|
||||
#[tokio::test]
|
||||
async fn test_unified_diff_interleaved_changes() {
|
||||
// Original file with six lines.
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("interleaved.txt");
|
||||
@@ -1014,7 +1187,10 @@ mod tests {
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let expected_diff = r#"@@ -1,6 +1,7 @@
|
||||
a
|
||||
@@ -1037,7 +1213,15 @@ mod tests {
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let contents = fs::read_to_string(path).unwrap();
|
||||
assert_eq!(
|
||||
contents,
|
||||
@@ -1052,8 +1236,8 @@ g
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_fails_on_write_error() {
|
||||
#[tokio::test]
|
||||
async fn test_apply_patch_fails_on_write_error() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("readonly.txt");
|
||||
fs::write(&path, "before\n").unwrap();
|
||||
@@ -1068,7 +1252,14 @@ g
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let result = apply_patch(&patch, &mut stdout, &mut stderr);
|
||||
let result = apply_patch(
|
||||
&patch,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
//! The parser below is a little more lenient than the explicit spec and allows for
|
||||
//! leading/trailing whitespace around patch markers.
|
||||
use crate::ApplyPatchArgs;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
#[cfg(test)]
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -76,11 +79,28 @@ pub enum Hunk {
|
||||
}
|
||||
|
||||
impl Hunk {
|
||||
pub fn resolve_path(&self, cwd: &Path) -> PathBuf {
|
||||
pub fn resolve_path(&self, cwd: &AbsolutePathBuf) -> AbsolutePathBuf {
|
||||
let path = match self {
|
||||
Hunk::UpdateFile { path, .. } => path,
|
||||
Hunk::AddFile { .. } | Hunk::DeleteFile { .. } => self.path(),
|
||||
};
|
||||
AbsolutePathBuf::resolve_path_against_base(path, cwd)
|
||||
}
|
||||
|
||||
/// Returns the path affected by this hunk, using the move destination for rename hunks.
|
||||
pub fn path(&self) -> &Path {
|
||||
match self {
|
||||
Hunk::AddFile { path, .. } => cwd.join(path),
|
||||
Hunk::DeleteFile { path } => cwd.join(path),
|
||||
Hunk::UpdateFile { path, .. } => cwd.join(path),
|
||||
Hunk::AddFile { path, .. } => path,
|
||||
Hunk::DeleteFile { path } => path,
|
||||
Hunk::UpdateFile {
|
||||
move_path: Some(path),
|
||||
..
|
||||
} => path,
|
||||
Hunk::UpdateFile {
|
||||
path,
|
||||
move_path: None,
|
||||
..
|
||||
} => path,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -583,6 +603,108 @@ fn test_parse_patch() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let absolute_delete = dir.path().join("absolute-delete.py").abs();
|
||||
let absolute_update = dir.path().join("absolute-update.py").abs();
|
||||
let patch_text = format!(
|
||||
r#"*** Begin Patch
|
||||
*** Add File: relative-add.py
|
||||
+content
|
||||
*** Delete File: {}
|
||||
*** Update File: {}
|
||||
@@
|
||||
-old
|
||||
+new
|
||||
*** End Patch"#,
|
||||
absolute_delete.display(),
|
||||
absolute_update.display()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_patch_text(&patch_text, ParseMode::Strict)
|
||||
.unwrap()
|
||||
.hunks,
|
||||
vec![
|
||||
AddFile {
|
||||
path: PathBuf::from("relative-add.py"),
|
||||
contents: "content\n".to_string()
|
||||
},
|
||||
DeleteFile {
|
||||
path: absolute_delete.to_path_buf()
|
||||
},
|
||||
UpdateFile {
|
||||
path: absolute_update.to_path_buf(),
|
||||
move_path: None,
|
||||
chunks: vec![UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: vec!["old".to_string()],
|
||||
new_lines: vec!["new".to_string()],
|
||||
is_end_of_file: false
|
||||
}]
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() {
|
||||
let cwd_dir = tempfile::tempdir().unwrap();
|
||||
let cwd = cwd_dir.path().to_path_buf().abs();
|
||||
let absolute_dir = tempfile::tempdir().unwrap();
|
||||
let absolute_add = absolute_dir.path().join("absolute-add.py").abs();
|
||||
let absolute_delete = absolute_dir.path().join("absolute-delete.py").abs();
|
||||
let absolute_update = absolute_dir.path().join("absolute-update.py").abs();
|
||||
|
||||
for (hunk, expected_path) in [
|
||||
(
|
||||
AddFile {
|
||||
path: PathBuf::from("relative-add.py"),
|
||||
contents: String::new(),
|
||||
},
|
||||
cwd.join("relative-add.py"),
|
||||
),
|
||||
(
|
||||
DeleteFile {
|
||||
path: PathBuf::from("relative-delete.py"),
|
||||
},
|
||||
cwd.join("relative-delete.py"),
|
||||
),
|
||||
(
|
||||
UpdateFile {
|
||||
path: PathBuf::from("relative-update.py"),
|
||||
move_path: None,
|
||||
chunks: Vec::new(),
|
||||
},
|
||||
cwd.join("relative-update.py"),
|
||||
),
|
||||
(
|
||||
AddFile {
|
||||
path: absolute_add.to_path_buf(),
|
||||
contents: String::new(),
|
||||
},
|
||||
absolute_add,
|
||||
),
|
||||
(
|
||||
DeleteFile {
|
||||
path: absolute_delete.to_path_buf(),
|
||||
},
|
||||
absolute_delete,
|
||||
),
|
||||
(
|
||||
UpdateFile {
|
||||
path: absolute_update.to_path_buf(),
|
||||
move_path: None,
|
||||
chunks: Vec::new(),
|
||||
},
|
||||
absolute_update,
|
||||
),
|
||||
] {
|
||||
assert_eq!(hunk.resolve_path(&cwd), expected_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_patch_lenient() {
|
||||
let patch_text = r#"*** Begin Patch
|
||||
|
||||
@@ -48,7 +48,30 @@ pub fn run_main() -> i32 {
|
||||
|
||||
let mut stdout = std::io::stdout();
|
||||
let mut stderr = std::io::stderr();
|
||||
match crate::apply_patch(&patch_arg, &mut stdout, &mut stderr) {
|
||||
let cwd = match codex_utils_absolute_path::AbsolutePathBuf::current_dir() {
|
||||
Ok(cwd) => cwd,
|
||||
Err(err) => {
|
||||
eprintln!("Error: Failed to determine current directory.\n{err}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(runtime) => runtime,
|
||||
Err(err) => {
|
||||
eprintln!("Error: Failed to initialize runtime.\n{err}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
match runtime.block_on(crate::apply_patch(
|
||||
&patch_arg,
|
||||
&cwd,
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
codex_exec_server::LOCAL_FS.as_ref(),
|
||||
)) {
|
||||
Ok(()) => {
|
||||
// Flush to ensure output ordering when used in pipelines.
|
||||
let _ = stdout.flush();
|
||||
|
||||
Reference in New Issue
Block a user