1320 lines
42 KiB
Rust
1320 lines
42 KiB
Rust
|
|
mod invocation;
|
|||
|
|
mod parser;
|
|||
|
|
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::FileSystemSandboxContext;
|
|||
|
|
use codex_exec_server::RemoveOptions;
|
|||
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|||
|
|
pub use parser::Hunk;
|
|||
|
|
pub use parser::ParseError;
|
|||
|
|
use parser::ParseError::*;
|
|||
|
|
pub use parser::UpdateFileChunk;
|
|||
|
|
pub use parser::parse_patch;
|
|||
|
|
pub use parser::parse_patch_streaming;
|
|||
|
|
use similar::TextDiff;
|
|||
|
|
use thiserror::Error;
|
|||
|
|
|
|||
|
|
pub use invocation::maybe_parse_apply_patch_verified;
|
|||
|
|
pub use standalone_executable::main;
|
|||
|
|
|
|||
|
|
use crate::invocation::ExtractHeredocError;
|
|||
|
|
|
|||
|
|
/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool.
|
|||
|
|
pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
|
|||
|
|
|
|||
|
|
/// Special argv[1] flag used when the Codex executable self-invokes to run the
|
|||
|
|
/// internal `apply_patch` path.
|
|||
|
|
///
|
|||
|
|
/// Although this constant lives in `codex-apply-patch` (to avoid forcing
|
|||
|
|
/// `codex-arg0` to depend on `codex-core`), it remains part of the "codex core"
|
|||
|
|
/// process-invocation contract for the standalone `apply_patch` command
|
|||
|
|
/// surface.
|
|||
|
|
pub const CODEX_CORE_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch";
|
|||
|
|
|
|||
|
|
#[derive(Debug, Error, PartialEq)]
|
|||
|
|
pub enum ApplyPatchError {
|
|||
|
|
#[error(transparent)]
|
|||
|
|
ParseError(#[from] ParseError),
|
|||
|
|
#[error(transparent)]
|
|||
|
|
IoError(#[from] IoError),
|
|||
|
|
/// Error that occurs while computing replacements when applying patch chunks
|
|||
|
|
#[error("{0}")]
|
|||
|
|
ComputeReplacements(String),
|
|||
|
|
/// A raw patch body was provided without an explicit `apply_patch` invocation.
|
|||
|
|
#[error(
|
|||
|
|
"patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"<patch>\"]"
|
|||
|
|
)]
|
|||
|
|
ImplicitInvocation,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl From<std::io::Error> for ApplyPatchError {
|
|||
|
|
fn from(err: std::io::Error) -> Self {
|
|||
|
|
ApplyPatchError::IoError(IoError {
|
|||
|
|
context: "I/O error".to_string(),
|
|||
|
|
source: err,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl From<&std::io::Error> for ApplyPatchError {
|
|||
|
|
fn from(err: &std::io::Error) -> Self {
|
|||
|
|
ApplyPatchError::IoError(IoError {
|
|||
|
|
context: "I/O error".to_string(),
|
|||
|
|
source: std::io::Error::new(err.kind(), err.to_string()),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Debug, Error)]
|
|||
|
|
#[error("{context}: {source}")]
|
|||
|
|
pub struct IoError {
|
|||
|
|
context: String,
|
|||
|
|
#[source]
|
|||
|
|
source: std::io::Error,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl PartialEq for IoError {
|
|||
|
|
fn eq(&self, other: &Self) -> bool {
|
|||
|
|
self.context == other.context && self.source.to_string() == other.source.to_string()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument
|
|||
|
|
/// parsed into hunks.
|
|||
|
|
#[derive(Debug, PartialEq)]
|
|||
|
|
pub struct ApplyPatchArgs {
|
|||
|
|
pub patch: String,
|
|||
|
|
pub hunks: Vec<Hunk>,
|
|||
|
|
pub workdir: Option<String>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Debug, PartialEq)]
|
|||
|
|
pub enum ApplyPatchFileChange {
|
|||
|
|
Add {
|
|||
|
|
content: String,
|
|||
|
|
},
|
|||
|
|
Delete {
|
|||
|
|
content: String,
|
|||
|
|
},
|
|||
|
|
Update {
|
|||
|
|
unified_diff: String,
|
|||
|
|
move_path: Option<PathBuf>,
|
|||
|
|
/// new_content that will result after the unified_diff is applied.
|
|||
|
|
new_content: String,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Debug, PartialEq)]
|
|||
|
|
pub enum MaybeApplyPatchVerified {
|
|||
|
|
/// `argv` corresponded to an `apply_patch` invocation, and these are the
|
|||
|
|
/// resulting proposed file changes.
|
|||
|
|
Body(ApplyPatchAction),
|
|||
|
|
/// `argv` could not be parsed to determine whether it corresponds to an
|
|||
|
|
/// `apply_patch` invocation.
|
|||
|
|
ShellParseError(ExtractHeredocError),
|
|||
|
|
/// `argv` corresponded to an `apply_patch` invocation, but it could not
|
|||
|
|
/// be fulfilled due to the specified error.
|
|||
|
|
CorrectnessError(ApplyPatchError),
|
|||
|
|
/// `argv` decidedly did not correspond to an `apply_patch` invocation.
|
|||
|
|
NotApplyPatch,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// ApplyPatchAction is the result of parsing an `apply_patch` command. By
|
|||
|
|
/// construction, all paths should be absolute paths.
|
|||
|
|
#[derive(Debug, PartialEq)]
|
|||
|
|
pub struct ApplyPatchAction {
|
|||
|
|
changes: HashMap<PathBuf, ApplyPatchFileChange>,
|
|||
|
|
|
|||
|
|
/// The raw patch argument that can be used to apply the patch. i.e., if the
|
|||
|
|
/// original arg was parsed in "lenient" mode with a
|
|||
|
|
/// heredoc, this should be the value without the heredoc wrapper.
|
|||
|
|
pub patch: String,
|
|||
|
|
|
|||
|
|
/// The working directory that was used to resolve relative paths in the patch.
|
|||
|
|
pub cwd: AbsolutePathBuf,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl ApplyPatchAction {
|
|||
|
|
pub fn is_empty(&self) -> bool {
|
|||
|
|
self.changes.is_empty()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Returns the changes that would be made by applying the patch.
|
|||
|
|
pub fn changes(&self) -> &HashMap<PathBuf, ApplyPatchFileChange> {
|
|||
|
|
&self.changes
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Should be used exclusively for testing. (Not worth the overhead of
|
|||
|
|
/// creating a feature flag for this.)
|
|||
|
|
pub fn new_add_for_test(path: &AbsolutePathBuf, content: String) -> Self {
|
|||
|
|
#[expect(clippy::expect_used)]
|
|||
|
|
let filename = path
|
|||
|
|
.file_name()
|
|||
|
|
.expect("path should not be empty")
|
|||
|
|
.to_string_lossy();
|
|||
|
|
let patch = format!(
|
|||
|
|
r#"*** Begin Patch
|
|||
|
|
*** Update File: {filename}
|
|||
|
|
@@
|
|||
|
|
+ {content}
|
|||
|
|
*** End Patch"#,
|
|||
|
|
);
|
|||
|
|
let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]);
|
|||
|
|
#[expect(clippy::expect_used)]
|
|||
|
|
Self {
|
|||
|
|
changes,
|
|||
|
|
cwd: path.parent().expect("path should have parent"),
|
|||
|
|
patch,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Applies the patch and prints the result to stdout/stderr.
|
|||
|
|
pub async fn apply_patch(
|
|||
|
|
patch: &str,
|
|||
|
|
cwd: &AbsolutePathBuf,
|
|||
|
|
stdout: &mut impl std::io::Write,
|
|||
|
|
stderr: &mut impl std::io::Write,
|
|||
|
|
fs: &dyn ExecutorFileSystem,
|
|||
|
|
sandbox: Option<&FileSystemSandboxContext>,
|
|||
|
|
) -> Result<(), ApplyPatchError> {
|
|||
|
|
let hunks = match parse_patch(patch) {
|
|||
|
|
Ok(source) => source.hunks,
|
|||
|
|
Err(e) => {
|
|||
|
|
match &e {
|
|||
|
|
InvalidPatchError(message) => {
|
|||
|
|
writeln!(stderr, "Invalid patch: {message}").map_err(ApplyPatchError::from)?;
|
|||
|
|
}
|
|||
|
|
InvalidHunkError {
|
|||
|
|
message,
|
|||
|
|
line_number,
|
|||
|
|
} => {
|
|||
|
|
writeln!(
|
|||
|
|
stderr,
|
|||
|
|
"Invalid patch hunk on line {line_number}: {message}"
|
|||
|
|
)
|
|||
|
|
.map_err(ApplyPatchError::from)?;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return Err(ApplyPatchError::ParseError(e));
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
apply_hunks(&hunks, cwd, stdout, stderr, fs, sandbox).await?;
|
|||
|
|
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Applies hunks and continues to update stdout/stderr
|
|||
|
|
pub async fn apply_hunks(
|
|||
|
|
hunks: &[Hunk],
|
|||
|
|
cwd: &AbsolutePathBuf,
|
|||
|
|
stdout: &mut impl std::io::Write,
|
|||
|
|
stderr: &mut impl std::io::Write,
|
|||
|
|
fs: &dyn ExecutorFileSystem,
|
|||
|
|
sandbox: Option<&FileSystemSandboxContext>,
|
|||
|
|
) -> Result<(), ApplyPatchError> {
|
|||
|
|
// Delegate to a helper that applies each hunk to the filesystem.
|
|||
|
|
match apply_hunks_to_files(hunks, cwd, fs, sandbox).await {
|
|||
|
|
Ok(affected) => {
|
|||
|
|
print_summary(&affected, stdout).map_err(ApplyPatchError::from)?;
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
Err(err) => {
|
|||
|
|
let msg = err.to_string();
|
|||
|
|
writeln!(stderr, "{msg}").map_err(ApplyPatchError::from)?;
|
|||
|
|
if let Some(io) = err.downcast_ref::<std::io::Error>() {
|
|||
|
|
Err(ApplyPatchError::from(io))
|
|||
|
|
} else {
|
|||
|
|
Err(ApplyPatchError::IoError(IoError {
|
|||
|
|
context: msg,
|
|||
|
|
source: std::io::Error::other(err),
|
|||
|
|
}))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 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, preserving the path spelling
|
|||
|
|
/// from the patch for user-facing summaries.
|
|||
|
|
pub struct AffectedPaths {
|
|||
|
|
pub added: Vec<PathBuf>,
|
|||
|
|
pub modified: Vec<PathBuf>,
|
|||
|
|
pub deleted: Vec<PathBuf>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Apply the hunks to the filesystem, returning which files were added, modified, or deleted.
|
|||
|
|
/// Returns an error if the patch could not be applied.
|
|||
|
|
async fn apply_hunks_to_files(
|
|||
|
|
hunks: &[Hunk],
|
|||
|
|
cwd: &AbsolutePathBuf,
|
|||
|
|
fs: &dyn ExecutorFileSystem,
|
|||
|
|
sandbox: Option<&FileSystemSandboxContext>,
|
|||
|
|
) -> anyhow::Result<AffectedPaths> {
|
|||
|
|
if hunks.is_empty() {
|
|||
|
|
anyhow::bail!("No files were modified.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let mut added: Vec<PathBuf> = Vec::new();
|
|||
|
|
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 { contents, .. } => {
|
|||
|
|
write_file_with_missing_parent_retry(
|
|||
|
|
fs,
|
|||
|
|
&path_abs,
|
|||
|
|
contents.clone().into_bytes(),
|
|||
|
|
sandbox,
|
|||
|
|
)
|
|||
|
|
.await?;
|
|||
|
|
added.push(affected_path);
|
|||
|
|
}
|
|||
|
|
Hunk::DeleteFile { .. } => {
|
|||
|
|
let result: io::Result<()> = async {
|
|||
|
|
let metadata = fs.get_metadata(&path_abs, sandbox).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,
|
|||
|
|
},
|
|||
|
|
sandbox,
|
|||
|
|
)
|
|||
|
|
.await
|
|||
|
|
}
|
|||
|
|
.await;
|
|||
|
|
result.with_context(|| format!("Failed to delete file {}", path_abs.display()))?;
|
|||
|
|
deleted.push(affected_path);
|
|||
|
|
}
|
|||
|
|
Hunk::UpdateFile {
|
|||
|
|
move_path, chunks, ..
|
|||
|
|
} => {
|
|||
|
|
let AppliedPatch { new_contents, .. } =
|
|||
|
|
derive_new_contents_from_chunks(&path_abs, chunks, fs, sandbox).await?;
|
|||
|
|
if let Some(dest) = move_path {
|
|||
|
|
let dest_abs = AbsolutePathBuf::resolve_path_against_base(dest, cwd);
|
|||
|
|
write_file_with_missing_parent_retry(
|
|||
|
|
fs,
|
|||
|
|
&dest_abs,
|
|||
|
|
new_contents.into_bytes(),
|
|||
|
|
sandbox,
|
|||
|
|
)
|
|||
|
|
.await?;
|
|||
|
|
let result: io::Result<()> = async {
|
|||
|
|
let metadata = fs.get_metadata(&path_abs, sandbox).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,
|
|||
|
|
},
|
|||
|
|
sandbox,
|
|||
|
|
)
|
|||
|
|
.await
|
|||
|
|
}
|
|||
|
|
.await;
|
|||
|
|
result.with_context(|| {
|
|||
|
|
format!("Failed to remove original {}", path_abs.display())
|
|||
|
|
})?;
|
|||
|
|
modified.push(affected_path);
|
|||
|
|
} else {
|
|||
|
|
fs.write_file(&path_abs, new_contents.into_bytes(), sandbox)
|
|||
|
|
.await
|
|||
|
|
.with_context(|| format!("Failed to write file {}", path_abs.display()))?;
|
|||
|
|
modified.push(affected_path);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Ok(AffectedPaths {
|
|||
|
|
added,
|
|||
|
|
modified,
|
|||
|
|
deleted,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async fn write_file_with_missing_parent_retry(
|
|||
|
|
fs: &dyn ExecutorFileSystem,
|
|||
|
|
path_abs: &AbsolutePathBuf,
|
|||
|
|
contents: Vec<u8>,
|
|||
|
|
sandbox: Option<&FileSystemSandboxContext>,
|
|||
|
|
) -> anyhow::Result<()> {
|
|||
|
|
match fs.write_file(path_abs, contents.clone(), sandbox).await {
|
|||
|
|
Ok(()) => Ok(()),
|
|||
|
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
|||
|
|
if let Some(parent_abs) = path_abs.parent() {
|
|||
|
|
fs.create_directory(
|
|||
|
|
&parent_abs,
|
|||
|
|
CreateDirectoryOptions { recursive: true },
|
|||
|
|
sandbox,
|
|||
|
|
)
|
|||
|
|
.await
|
|||
|
|
.with_context(|| {
|
|||
|
|
format!(
|
|||
|
|
"Failed to create parent directories for {}",
|
|||
|
|
path_abs.display()
|
|||
|
|
)
|
|||
|
|
})?;
|
|||
|
|
}
|
|||
|
|
fs.write_file(path_abs, contents, sandbox)
|
|||
|
|
.await
|
|||
|
|
.with_context(|| format!("Failed to write file {}", path_abs.display()))?;
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
Err(err) => {
|
|||
|
|
Err(err).with_context(|| format!("Failed to write file {}", path_abs.display()))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
struct AppliedPatch {
|
|||
|
|
original_contents: String,
|
|||
|
|
new_contents: String,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Return *only* the new file contents (joined into a single `String`) after
|
|||
|
|
/// applying the chunks to the file at `path`.
|
|||
|
|
async fn derive_new_contents_from_chunks(
|
|||
|
|
path_abs: &AbsolutePathBuf,
|
|||
|
|
chunks: &[UpdateFileChunk],
|
|||
|
|
fs: &dyn ExecutorFileSystem,
|
|||
|
|
sandbox: Option<&FileSystemSandboxContext>,
|
|||
|
|
) -> std::result::Result<AppliedPatch, ApplyPatchError> {
|
|||
|
|
let original_contents = fs.read_file_text(path_abs, sandbox).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();
|
|||
|
|
|
|||
|
|
// Drop the trailing empty element that results from the final newline so
|
|||
|
|
// that line counts match the behaviour of standard `diff`.
|
|||
|
|
if original_lines.last().is_some_and(String::is_empty) {
|
|||
|
|
original_lines.pop();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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) {
|
|||
|
|
new_lines.push(String::new());
|
|||
|
|
}
|
|||
|
|
let new_contents = new_lines.join("\n");
|
|||
|
|
Ok(AppliedPatch {
|
|||
|
|
original_contents,
|
|||
|
|
new_contents,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Compute a list of replacements needed to transform `original_lines` into the
|
|||
|
|
/// new lines, given the patch `chunks`. Each replacement is returned as
|
|||
|
|
/// `(start_index, old_len, new_lines)`.
|
|||
|
|
fn compute_replacements(
|
|||
|
|
original_lines: &[String],
|
|||
|
|
path: &Path,
|
|||
|
|
chunks: &[UpdateFileChunk],
|
|||
|
|
) -> std::result::Result<Vec<(usize, usize, Vec<String>)>, ApplyPatchError> {
|
|||
|
|
let mut replacements: Vec<(usize, usize, Vec<String>)> = Vec::new();
|
|||
|
|
let mut line_index: usize = 0;
|
|||
|
|
|
|||
|
|
for chunk in chunks {
|
|||
|
|
// If a chunk has a `change_context`, we use seek_sequence to find it, then
|
|||
|
|
// adjust our `line_index` to continue from there.
|
|||
|
|
if let Some(ctx_line) = &chunk.change_context {
|
|||
|
|
if let Some(idx) = seek_sequence::seek_sequence(
|
|||
|
|
original_lines,
|
|||
|
|
std::slice::from_ref(ctx_line),
|
|||
|
|
line_index,
|
|||
|
|
/*eof*/ false,
|
|||
|
|
) {
|
|||
|
|
line_index = idx + 1;
|
|||
|
|
} else {
|
|||
|
|
return Err(ApplyPatchError::ComputeReplacements(format!(
|
|||
|
|
"Failed to find context '{}' in {}",
|
|||
|
|
ctx_line,
|
|||
|
|
path.display()
|
|||
|
|
)));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if chunk.old_lines.is_empty() {
|
|||
|
|
// Pure addition (no old lines). We'll add them at the end or just
|
|||
|
|
// before the final empty line if one exists.
|
|||
|
|
let insertion_idx = if original_lines.last().is_some_and(String::is_empty) {
|
|||
|
|
original_lines.len() - 1
|
|||
|
|
} else {
|
|||
|
|
original_lines.len()
|
|||
|
|
};
|
|||
|
|
replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Otherwise, try to match the existing lines in the file with the old lines
|
|||
|
|
// from the chunk. If found, schedule that region for replacement.
|
|||
|
|
// Attempt to locate the `old_lines` verbatim within the file. In many
|
|||
|
|
// real‑world diffs the last element of `old_lines` is an *empty* string
|
|||
|
|
// representing the terminating newline of the region being replaced.
|
|||
|
|
// This sentinel is not present in `original_lines` because we strip the
|
|||
|
|
// trailing empty slice emitted by `split('\n')`. If a direct search
|
|||
|
|
// fails and the pattern ends with an empty string, retry without that
|
|||
|
|
// final element so that modifications touching the end‑of‑file can be
|
|||
|
|
// located reliably.
|
|||
|
|
|
|||
|
|
let mut pattern: &[String] = &chunk.old_lines;
|
|||
|
|
let mut found =
|
|||
|
|
seek_sequence::seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
|
|||
|
|
|
|||
|
|
let mut new_slice: &[String] = &chunk.new_lines;
|
|||
|
|
|
|||
|
|
if found.is_none() && pattern.last().is_some_and(String::is_empty) {
|
|||
|
|
// Retry without the trailing empty line which represents the final
|
|||
|
|
// newline in the file.
|
|||
|
|
pattern = &pattern[..pattern.len() - 1];
|
|||
|
|
if new_slice.last().is_some_and(String::is_empty) {
|
|||
|
|
new_slice = &new_slice[..new_slice.len() - 1];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
found = seek_sequence::seek_sequence(
|
|||
|
|
original_lines,
|
|||
|
|
pattern,
|
|||
|
|
line_index,
|
|||
|
|
chunk.is_end_of_file,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if let Some(start_idx) = found {
|
|||
|
|
replacements.push((start_idx, pattern.len(), new_slice.to_vec()));
|
|||
|
|
line_index = start_idx + pattern.len();
|
|||
|
|
} else {
|
|||
|
|
return Err(ApplyPatchError::ComputeReplacements(format!(
|
|||
|
|
"Failed to find expected lines in {}:\n{}",
|
|||
|
|
path.display(),
|
|||
|
|
chunk.old_lines.join("\n"),
|
|||
|
|
)));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
replacements.sort_by(|(lhs_idx, _, _), (rhs_idx, _, _)| lhs_idx.cmp(rhs_idx));
|
|||
|
|
|
|||
|
|
Ok(replacements)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Apply the `(start_index, old_len, new_lines)` replacements to `original_lines`,
|
|||
|
|
/// returning the modified file contents as a vector of lines.
|
|||
|
|
fn apply_replacements(
|
|||
|
|
mut lines: Vec<String>,
|
|||
|
|
replacements: &[(usize, usize, Vec<String>)],
|
|||
|
|
) -> Vec<String> {
|
|||
|
|
// We must apply replacements in descending order so that earlier replacements
|
|||
|
|
// don't shift the positions of later ones.
|
|||
|
|
for (start_idx, old_len, new_segment) in replacements.iter().rev() {
|
|||
|
|
let start_idx = *start_idx;
|
|||
|
|
let old_len = *old_len;
|
|||
|
|
|
|||
|
|
// Remove old lines.
|
|||
|
|
for _ in 0..old_len {
|
|||
|
|
if start_idx < lines.len() {
|
|||
|
|
lines.remove(start_idx);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Insert new lines.
|
|||
|
|
for (offset, new_line) in new_segment.iter().enumerate() {
|
|||
|
|
lines.insert(start_idx + offset, new_line.clone());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
lines
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Intended result of a file update for apply_patch.
|
|||
|
|
#[derive(Debug, Eq, PartialEq)]
|
|||
|
|
pub struct ApplyPatchFileUpdate {
|
|||
|
|
unified_diff: String,
|
|||
|
|
content: String,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub async fn unified_diff_from_chunks(
|
|||
|
|
path_abs: &AbsolutePathBuf,
|
|||
|
|
chunks: &[UpdateFileChunk],
|
|||
|
|
fs: &dyn ExecutorFileSystem,
|
|||
|
|
sandbox: Option<&FileSystemSandboxContext>,
|
|||
|
|
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
|
|||
|
|
unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs, sandbox).await
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub async fn unified_diff_from_chunks_with_context(
|
|||
|
|
path_abs: &AbsolutePathBuf,
|
|||
|
|
chunks: &[UpdateFileChunk],
|
|||
|
|
context: usize,
|
|||
|
|
fs: &dyn ExecutorFileSystem,
|
|||
|
|
sandbox: Option<&FileSystemSandboxContext>,
|
|||
|
|
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
|
|||
|
|
let AppliedPatch {
|
|||
|
|
original_contents,
|
|||
|
|
new_contents,
|
|||
|
|
} = derive_new_contents_from_chunks(path_abs, chunks, fs, sandbox).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 {
|
|||
|
|
unified_diff,
|
|||
|
|
content: new_contents,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Print the summary of changes in git-style format.
|
|||
|
|
/// Write a summary of changes to the given writer.
|
|||
|
|
pub fn print_summary(
|
|||
|
|
affected: &AffectedPaths,
|
|||
|
|
out: &mut impl std::io::Write,
|
|||
|
|
) -> std::io::Result<()> {
|
|||
|
|
writeln!(out, "Success. Updated the following files:")?;
|
|||
|
|
for path in &affected.added {
|
|||
|
|
writeln!(out, "A {}", path.display())?;
|
|||
|
|
}
|
|||
|
|
for path in &affected.modified {
|
|||
|
|
writeln!(out, "M {}", path.display())?;
|
|||
|
|
}
|
|||
|
|
for path in &affected.deleted {
|
|||
|
|
writeln!(out, "D {}", path.display())?;
|
|||
|
|
}
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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;
|
|||
|
|
use tempfile::tempdir;
|
|||
|
|
|
|||
|
|
/// Helper to construct a patch with the given body.
|
|||
|
|
fn wrap_patch(body: &str) -> String {
|
|||
|
|
format!("*** Begin Patch\n{body}\n*** End Patch")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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!(
|
|||
|
|
r#"*** Add File: {}
|
|||
|
|
+ab
|
|||
|
|
+cd"#,
|
|||
|
|
path.display()
|
|||
|
|
));
|
|||
|
|
let mut stdout = Vec::new();
|
|||
|
|
let mut stderr = Vec::new();
|
|||
|
|
apply_patch(
|
|||
|
|
&patch,
|
|||
|
|
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
|||
|
|
&mut stdout,
|
|||
|
|
&mut stderr,
|
|||
|
|
LOCAL_FS.as_ref(),
|
|||
|
|
/*sandbox*/ None,
|
|||
|
|
)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
// Verify expected stdout and stderr outputs.
|
|||
|
|
let stdout_str = String::from_utf8(stdout).unwrap();
|
|||
|
|
let stderr_str = String::from_utf8(stderr).unwrap();
|
|||
|
|
let expected_out = format!(
|
|||
|
|
"Success. Updated the following files:\nA {}\n",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
assert_eq!(stdout_str, expected_out);
|
|||
|
|
assert_eq!(stderr_str, "");
|
|||
|
|
let contents = fs::read_to_string(path).unwrap();
|
|||
|
|
assert_eq!(contents, "ab\ncd\n");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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(),
|
|||
|
|
/*sandbox*/ None,
|
|||
|
|
)
|
|||
|
|
.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,
|
|||
|
|
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
|||
|
|
&mut stdout,
|
|||
|
|
&mut stderr,
|
|||
|
|
LOCAL_FS.as_ref(),
|
|||
|
|
/*sandbox*/ None,
|
|||
|
|
)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
let stdout_str = String::from_utf8(stdout).unwrap();
|
|||
|
|
let stderr_str = String::from_utf8(stderr).unwrap();
|
|||
|
|
let expected_out = format!(
|
|||
|
|
"Success. Updated the following files:\nD {}\n",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
assert_eq!(stdout_str, expected_out);
|
|||
|
|
assert_eq!(stderr_str, "");
|
|||
|
|
assert!(!path.exists());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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();
|
|||
|
|
let patch = wrap_patch(&format!(
|
|||
|
|
r#"*** Update File: {}
|
|||
|
|
@@
|
|||
|
|
foo
|
|||
|
|
-bar
|
|||
|
|
+baz"#,
|
|||
|
|
path.display()
|
|||
|
|
));
|
|||
|
|
let mut stdout = Vec::new();
|
|||
|
|
let mut stderr = Vec::new();
|
|||
|
|
apply_patch(
|
|||
|
|
&patch,
|
|||
|
|
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
|||
|
|
&mut stdout,
|
|||
|
|
&mut stderr,
|
|||
|
|
LOCAL_FS.as_ref(),
|
|||
|
|
/*sandbox*/ None,
|
|||
|
|
)
|
|||
|
|
.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();
|
|||
|
|
let expected_out = format!(
|
|||
|
|
"Success. Updated the following files:\nM {}\n",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
assert_eq!(stdout_str, expected_out);
|
|||
|
|
assert_eq!(stderr_str, "");
|
|||
|
|
let contents = fs::read_to_string(&path).unwrap();
|
|||
|
|
assert_eq!(contents, "foo\nbaz\n");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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");
|
|||
|
|
fs::write(&src, "line\n").unwrap();
|
|||
|
|
let patch = wrap_patch(&format!(
|
|||
|
|
r#"*** Update File: {}
|
|||
|
|
*** Move to: {}
|
|||
|
|
@@
|
|||
|
|
-line
|
|||
|
|
+line2"#,
|
|||
|
|
src.display(),
|
|||
|
|
dest.display()
|
|||
|
|
));
|
|||
|
|
let mut stdout = Vec::new();
|
|||
|
|
let mut stderr = Vec::new();
|
|||
|
|
apply_patch(
|
|||
|
|
&patch,
|
|||
|
|
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
|||
|
|
&mut stdout,
|
|||
|
|
&mut stderr,
|
|||
|
|
LOCAL_FS.as_ref(),
|
|||
|
|
/*sandbox*/ None,
|
|||
|
|
)
|
|||
|
|
.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();
|
|||
|
|
let expected_out = format!(
|
|||
|
|
"Success. Updated the following files:\nM {}\n",
|
|||
|
|
dest.display()
|
|||
|
|
);
|
|||
|
|
assert_eq!(stdout_str, expected_out);
|
|||
|
|
assert_eq!(stderr_str, "");
|
|||
|
|
assert!(!src.exists());
|
|||
|
|
let contents = fs::read_to_string(&dest).unwrap();
|
|||
|
|
assert_eq!(contents, "line2\n");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 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.
|
|||
|
|
#[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");
|
|||
|
|
fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
|
|||
|
|
// Construct an update patch with two separate change chunks.
|
|||
|
|
// The first chunk uses the line `foo` as context and transforms `bar` into `BAR`.
|
|||
|
|
// The second chunk uses `baz` as context and transforms `qux` into `QUX`.
|
|||
|
|
let patch = wrap_patch(&format!(
|
|||
|
|
r#"*** Update File: {}
|
|||
|
|
@@
|
|||
|
|
foo
|
|||
|
|
-bar
|
|||
|
|
+BAR
|
|||
|
|
@@
|
|||
|
|
baz
|
|||
|
|
-qux
|
|||
|
|
+QUX"#,
|
|||
|
|
path.display()
|
|||
|
|
));
|
|||
|
|
let mut stdout = Vec::new();
|
|||
|
|
let mut stderr = Vec::new();
|
|||
|
|
apply_patch(
|
|||
|
|
&patch,
|
|||
|
|
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
|||
|
|
&mut stdout,
|
|||
|
|
&mut stderr,
|
|||
|
|
LOCAL_FS.as_ref(),
|
|||
|
|
/*sandbox*/ None,
|
|||
|
|
)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
let stdout_str = String::from_utf8(stdout).unwrap();
|
|||
|
|
let stderr_str = String::from_utf8(stderr).unwrap();
|
|||
|
|
let expected_out = format!(
|
|||
|
|
"Success. Updated the following files:\nM {}\n",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
assert_eq!(stdout_str, expected_out);
|
|||
|
|
assert_eq!(stderr_str, "");
|
|||
|
|
let contents = fs::read_to_string(&path).unwrap();
|
|||
|
|
assert_eq!(contents, "foo\nBAR\nbaz\nQUX\n");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// A more involved `Update File` hunk that exercises additions, deletions and
|
|||
|
|
/// 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.
|
|||
|
|
#[tokio::test]
|
|||
|
|
async fn test_update_file_hunk_interleaved_changes() {
|
|||
|
|
let dir = tempdir().unwrap();
|
|||
|
|
let path = dir.path().join("interleaved.txt");
|
|||
|
|
|
|||
|
|
// Original file: six numbered lines.
|
|||
|
|
fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
|
|||
|
|
|
|||
|
|
// Patch performs:
|
|||
|
|
// • Replace `b` → `B`
|
|||
|
|
// • Replace `e` → `E` (using surrounding context)
|
|||
|
|
// • Append new line `g` at the end‑of‑file
|
|||
|
|
let patch = wrap_patch(&format!(
|
|||
|
|
r#"*** Update File: {}
|
|||
|
|
@@
|
|||
|
|
a
|
|||
|
|
-b
|
|||
|
|
+B
|
|||
|
|
@@
|
|||
|
|
c
|
|||
|
|
d
|
|||
|
|
-e
|
|||
|
|
+E
|
|||
|
|
@@
|
|||
|
|
f
|
|||
|
|
+g
|
|||
|
|
*** End of File"#,
|
|||
|
|
path.display()
|
|||
|
|
));
|
|||
|
|
|
|||
|
|
let mut stdout = Vec::new();
|
|||
|
|
let mut stderr = Vec::new();
|
|||
|
|
apply_patch(
|
|||
|
|
&patch,
|
|||
|
|
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
|||
|
|
&mut stdout,
|
|||
|
|
&mut stderr,
|
|||
|
|
LOCAL_FS.as_ref(),
|
|||
|
|
/*sandbox*/ None,
|
|||
|
|
)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
|
|||
|
|
let stdout_str = String::from_utf8(stdout).unwrap();
|
|||
|
|
let stderr_str = String::from_utf8(stderr).unwrap();
|
|||
|
|
|
|||
|
|
let expected_out = format!(
|
|||
|
|
"Success. Updated the following files:\nM {}\n",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
assert_eq!(stdout_str, expected_out);
|
|||
|
|
assert_eq!(stderr_str, "");
|
|||
|
|
|
|||
|
|
let contents = fs::read_to_string(&path).unwrap();
|
|||
|
|
assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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();
|
|||
|
|
let patch = wrap_patch(&format!(
|
|||
|
|
r#"*** Update File: {}
|
|||
|
|
@@
|
|||
|
|
+after-context
|
|||
|
|
+second-line
|
|||
|
|
@@
|
|||
|
|
line1
|
|||
|
|
-line2
|
|||
|
|
-line3
|
|||
|
|
+line2-replacement"#,
|
|||
|
|
path.display()
|
|||
|
|
));
|
|||
|
|
let mut stdout = Vec::new();
|
|||
|
|
let mut stderr = Vec::new();
|
|||
|
|
apply_patch(
|
|||
|
|
&patch,
|
|||
|
|
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
|||
|
|
&mut stdout,
|
|||
|
|
&mut stderr,
|
|||
|
|
LOCAL_FS.as_ref(),
|
|||
|
|
/*sandbox*/ None,
|
|||
|
|
)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
let contents = fs::read_to_string(path).unwrap();
|
|||
|
|
assert_eq!(
|
|||
|
|
contents,
|
|||
|
|
"line1\nline2-replacement\nafter-context\nsecond-line\n"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Ensure that patches authored with ASCII characters can update lines that
|
|||
|
|
/// contain typographic Unicode punctuation (e.g. EN DASH, NON-BREAKING
|
|||
|
|
/// HYPHEN). Historically `git apply` succeeds in such scenarios but our
|
|||
|
|
/// internal matcher failed requiring an exact byte-for-byte match. The
|
|||
|
|
/// fuzzy-matching pass that normalises common punctuation should now bridge
|
|||
|
|
/// the gap.
|
|||
|
|
#[tokio::test]
|
|||
|
|
async fn test_update_line_with_unicode_dash() {
|
|||
|
|
let dir = tempdir().unwrap();
|
|||
|
|
let path = dir.path().join("unicode.py");
|
|||
|
|
|
|||
|
|
// Original line contains EN DASH (\u{2013}) and NON-BREAKING HYPHEN (\u{2011}).
|
|||
|
|
let original = "import asyncio # local import \u{2013} avoids top\u{2011}level dep\n";
|
|||
|
|
std::fs::write(&path, original).unwrap();
|
|||
|
|
|
|||
|
|
// Patch uses plain ASCII dash / hyphen.
|
|||
|
|
let patch = wrap_patch(&format!(
|
|||
|
|
r#"*** Update File: {}
|
|||
|
|
@@
|
|||
|
|
-import asyncio # local import - avoids top-level dep
|
|||
|
|
+import asyncio # HELLO"#,
|
|||
|
|
path.display()
|
|||
|
|
));
|
|||
|
|
|
|||
|
|
let mut stdout = Vec::new();
|
|||
|
|
let mut stderr = Vec::new();
|
|||
|
|
apply_patch(
|
|||
|
|
&patch,
|
|||
|
|
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
|||
|
|
&mut stdout,
|
|||
|
|
&mut stderr,
|
|||
|
|
LOCAL_FS.as_ref(),
|
|||
|
|
/*sandbox*/ None,
|
|||
|
|
)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
|
|||
|
|
// File should now contain the replaced comment.
|
|||
|
|
let expected = "import asyncio # HELLO\n";
|
|||
|
|
let contents = std::fs::read_to_string(&path).unwrap();
|
|||
|
|
assert_eq!(contents, expected);
|
|||
|
|
|
|||
|
|
// Ensure success summary lists the file as modified.
|
|||
|
|
let stdout_str = String::from_utf8(stdout).unwrap();
|
|||
|
|
let expected_out = format!(
|
|||
|
|
"Success. Updated the following files:\nM {}\n",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
assert_eq!(stdout_str, expected_out);
|
|||
|
|
|
|||
|
|
// No stderr expected.
|
|||
|
|
assert_eq!(String::from_utf8(stderr).unwrap(), "");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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");
|
|||
|
|
fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
|
|||
|
|
let patch = wrap_patch(&format!(
|
|||
|
|
r#"*** Update File: {}
|
|||
|
|
@@
|
|||
|
|
foo
|
|||
|
|
-bar
|
|||
|
|
+BAR
|
|||
|
|
@@
|
|||
|
|
baz
|
|||
|
|
-qux
|
|||
|
|
+QUX"#,
|
|||
|
|
path.display()
|
|||
|
|
));
|
|||
|
|
let patch = parse_patch(&patch).unwrap();
|
|||
|
|
|
|||
|
|
let update_file_chunks = match patch.hunks.as_slice() {
|
|||
|
|
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
|||
|
|
_ => panic!("Expected a single UpdateFile hunk"),
|
|||
|
|
};
|
|||
|
|
let path_abs = path.as_path().abs();
|
|||
|
|
let diff = unified_diff_from_chunks(
|
|||
|
|
&path_abs,
|
|||
|
|
update_file_chunks,
|
|||
|
|
LOCAL_FS.as_ref(),
|
|||
|
|
/*sandbox*/ None,
|
|||
|
|
)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
let expected_diff = r#"@@ -1,4 +1,4 @@
|
|||
|
|
foo
|
|||
|
|
-bar
|
|||
|
|
+BAR
|
|||
|
|
baz
|
|||
|
|
-qux
|
|||
|
|
+QUX
|
|||
|
|
"#;
|
|||
|
|
let expected = ApplyPatchFileUpdate {
|
|||
|
|
unified_diff: expected_diff.to_string(),
|
|||
|
|
content: "foo\nBAR\nbaz\nQUX\n".to_string(),
|
|||
|
|
};
|
|||
|
|
assert_eq!(expected, diff);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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");
|
|||
|
|
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
|
|||
|
|
|
|||
|
|
let patch = wrap_patch(&format!(
|
|||
|
|
r#"*** Update File: {}
|
|||
|
|
@@
|
|||
|
|
-foo
|
|||
|
|
+FOO
|
|||
|
|
bar
|
|||
|
|
"#,
|
|||
|
|
path.display()
|
|||
|
|
));
|
|||
|
|
|
|||
|
|
let patch = parse_patch(&patch).unwrap();
|
|||
|
|
let chunks = match patch.hunks.as_slice() {
|
|||
|
|
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
|||
|
|
_ => panic!("Expected a single UpdateFile hunk"),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let path_abs = path.as_path().abs();
|
|||
|
|
let diff =
|
|||
|
|
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
let expected_diff = r#"@@ -1,2 +1,2 @@
|
|||
|
|
-foo
|
|||
|
|
+FOO
|
|||
|
|
bar
|
|||
|
|
"#;
|
|||
|
|
let expected = ApplyPatchFileUpdate {
|
|||
|
|
unified_diff: expected_diff.to_string(),
|
|||
|
|
content: "FOO\nbar\nbaz\n".to_string(),
|
|||
|
|
};
|
|||
|
|
assert_eq!(expected, diff);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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");
|
|||
|
|
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
|
|||
|
|
|
|||
|
|
let patch = wrap_patch(&format!(
|
|||
|
|
r#"*** Update File: {}
|
|||
|
|
@@
|
|||
|
|
foo
|
|||
|
|
bar
|
|||
|
|
-baz
|
|||
|
|
+BAZ
|
|||
|
|
"#,
|
|||
|
|
path.display()
|
|||
|
|
));
|
|||
|
|
|
|||
|
|
let patch = parse_patch(&patch).unwrap();
|
|||
|
|
let chunks = match patch.hunks.as_slice() {
|
|||
|
|
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
|||
|
|
_ => panic!("Expected a single UpdateFile hunk"),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let path_abs = path.as_path().abs();
|
|||
|
|
let diff =
|
|||
|
|
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
let expected_diff = r#"@@ -2,2 +2,2 @@
|
|||
|
|
bar
|
|||
|
|
-baz
|
|||
|
|
+BAZ
|
|||
|
|
"#;
|
|||
|
|
let expected = ApplyPatchFileUpdate {
|
|||
|
|
unified_diff: expected_diff.to_string(),
|
|||
|
|
content: "foo\nbar\nBAZ\n".to_string(),
|
|||
|
|
};
|
|||
|
|
assert_eq!(expected, diff);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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");
|
|||
|
|
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
|
|||
|
|
|
|||
|
|
let patch = wrap_patch(&format!(
|
|||
|
|
r#"*** Update File: {}
|
|||
|
|
@@
|
|||
|
|
+quux
|
|||
|
|
*** End of File
|
|||
|
|
"#,
|
|||
|
|
path.display()
|
|||
|
|
));
|
|||
|
|
|
|||
|
|
let patch = parse_patch(&patch).unwrap();
|
|||
|
|
let chunks = match patch.hunks.as_slice() {
|
|||
|
|
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
|||
|
|
_ => panic!("Expected a single UpdateFile hunk"),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let path_abs = path.as_path().abs();
|
|||
|
|
let diff =
|
|||
|
|
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
let expected_diff = r#"@@ -3 +3,2 @@
|
|||
|
|
baz
|
|||
|
|
+quux
|
|||
|
|
"#;
|
|||
|
|
let expected = ApplyPatchFileUpdate {
|
|||
|
|
unified_diff: expected_diff.to_string(),
|
|||
|
|
content: "foo\nbar\nbaz\nquux\n".to_string(),
|
|||
|
|
};
|
|||
|
|
assert_eq!(expected, diff);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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");
|
|||
|
|
fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
|
|||
|
|
|
|||
|
|
// Patch replaces two separate lines and appends a new one at EOF using
|
|||
|
|
// three distinct chunks.
|
|||
|
|
let patch_body = format!(
|
|||
|
|
r#"*** Update File: {}
|
|||
|
|
@@
|
|||
|
|
a
|
|||
|
|
-b
|
|||
|
|
+B
|
|||
|
|
@@
|
|||
|
|
d
|
|||
|
|
-e
|
|||
|
|
+E
|
|||
|
|
@@
|
|||
|
|
f
|
|||
|
|
+g
|
|||
|
|
*** End of File"#,
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
let patch = wrap_patch(&patch_body);
|
|||
|
|
|
|||
|
|
// Extract chunks then build the unified diff.
|
|||
|
|
let parsed = parse_patch(&patch).unwrap();
|
|||
|
|
let chunks = match parsed.hunks.as_slice() {
|
|||
|
|
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
|||
|
|
_ => panic!("Expected a single UpdateFile hunk"),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let path_abs = path.as_path().abs();
|
|||
|
|
let diff =
|
|||
|
|
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
|
|||
|
|
let expected_diff = r#"@@ -1,6 +1,7 @@
|
|||
|
|
a
|
|||
|
|
-b
|
|||
|
|
+B
|
|||
|
|
c
|
|||
|
|
d
|
|||
|
|
-e
|
|||
|
|
+E
|
|||
|
|
f
|
|||
|
|
+g
|
|||
|
|
"#;
|
|||
|
|
|
|||
|
|
let expected = ApplyPatchFileUpdate {
|
|||
|
|
unified_diff: expected_diff.to_string(),
|
|||
|
|
content: "a\nB\nc\nd\nE\nf\ng\n".to_string(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
assert_eq!(expected, diff);
|
|||
|
|
|
|||
|
|
let mut stdout = Vec::new();
|
|||
|
|
let mut stderr = Vec::new();
|
|||
|
|
apply_patch(
|
|||
|
|
&patch,
|
|||
|
|
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
|||
|
|
&mut stdout,
|
|||
|
|
&mut stderr,
|
|||
|
|
LOCAL_FS.as_ref(),
|
|||
|
|
/*sandbox*/ None,
|
|||
|
|
)
|
|||
|
|
.await
|
|||
|
|
.unwrap();
|
|||
|
|
let contents = fs::read_to_string(path).unwrap();
|
|||
|
|
assert_eq!(
|
|||
|
|
contents,
|
|||
|
|
r#"a
|
|||
|
|
B
|
|||
|
|
c
|
|||
|
|
d
|
|||
|
|
E
|
|||
|
|
f
|
|||
|
|
g
|
|||
|
|
"#
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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();
|
|||
|
|
let mut perms = fs::metadata(&path).unwrap().permissions();
|
|||
|
|
perms.set_readonly(true);
|
|||
|
|
fs::set_permissions(&path, perms).unwrap();
|
|||
|
|
|
|||
|
|
let patch = wrap_patch(&format!(
|
|||
|
|
"*** Update File: {}\n@@\n-before\n+after\n*** End Patch",
|
|||
|
|
path.display()
|
|||
|
|
));
|
|||
|
|
|
|||
|
|
let mut stdout = Vec::new();
|
|||
|
|
let mut stderr = Vec::new();
|
|||
|
|
let result = apply_patch(
|
|||
|
|
&patch,
|
|||
|
|
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
|||
|
|
&mut stdout,
|
|||
|
|
&mut stderr,
|
|||
|
|
LOCAL_FS.as_ref(),
|
|||
|
|
/*sandbox*/ None,
|
|||
|
|
)
|
|||
|
|
.await;
|
|||
|
|
assert!(result.is_err());
|
|||
|
|
}
|
|||
|
|
}
|