Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- codex-rs/agent-graph-store/src/error.rs +20 -0
- codex-rs/agent-graph-store/src/lib.rs +13 -0
- codex-rs/agent-graph-store/src/local.rs +344 -0
- codex-rs/agent-graph-store/src/store.rs +60 -0
- codex-rs/agent-graph-store/src/types.rs +42 -0
- codex-rs/apply-patch/src/file_update.rs +335 -0
- codex-rs/apply-patch/src/file_update_tests.rs +278 -0
- codex-rs/apply-patch/src/invocation.rs +1036 -0
- codex-rs/apply-patch/src/lib.rs +1444 -0
- codex-rs/apply-patch/src/main.rs +3 -0
- codex-rs/apply-patch/src/parser.rs +682 -0
- codex-rs/apply-patch/src/seek_sequence.rs +193 -0
- codex-rs/apply-patch/src/standalone_executable.rs +90 -0
- codex-rs/apply-patch/src/streaming_parser.rs +924 -0
- codex-rs/apply-patch/src/text_file.rs +121 -0
- codex-rs/apply-patch/tests/all.rs +3 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/.gitattributes +5 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/expected/bar.md +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/patch.txt +4 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/modify.txt +2 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/nested/new.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/delete.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/modify.txt +2 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/patch.txt +9 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/expected/multi.txt +4 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/input/multi.txt +4 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/patch.txt +9 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/old/other.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/renamed/dir/name.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/name.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/other.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/patch.txt +7 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/expected/foo.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/input/foo.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/patch.txt +2 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/expected/modify.txt +2 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/input/modify.txt +2 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/patch.txt +6 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/expected/foo.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/input/foo.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/patch.txt +3 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/expected/foo.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/input/foo.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/patch.txt +3 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/expected/foo.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/input/foo.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/patch.txt +6 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/old/other.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/renamed/dir/name.txt +1 -0
- codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/name.txt +1 -0
codex-rs/agent-graph-store/src/error.rs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// Result type returned by agent graph store operations.
|
| 2 |
+
pub type AgentGraphStoreResult<T> = Result<T, AgentGraphStoreError>;
|
| 3 |
+
|
| 4 |
+
/// Error type shared by agent graph store implementations.
|
| 5 |
+
#[derive(Debug, thiserror::Error)]
|
| 6 |
+
pub enum AgentGraphStoreError {
|
| 7 |
+
/// The caller supplied invalid request data.
|
| 8 |
+
#[error("invalid agent graph store request: {message}")]
|
| 9 |
+
InvalidRequest {
|
| 10 |
+
/// User-facing explanation of the invalid request.
|
| 11 |
+
message: String,
|
| 12 |
+
},
|
| 13 |
+
|
| 14 |
+
/// Catch-all for implementation failures that do not fit a more specific category.
|
| 15 |
+
#[error("agent graph store internal error: {message}")]
|
| 16 |
+
Internal {
|
| 17 |
+
/// User-facing explanation of the implementation failure.
|
| 18 |
+
message: String,
|
| 19 |
+
},
|
| 20 |
+
}
|
codex-rs/agent-graph-store/src/lib.rs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Storage-neutral parent/child topology for thread-spawned agents.
|
| 2 |
+
|
| 3 |
+
mod error;
|
| 4 |
+
mod local;
|
| 5 |
+
mod store;
|
| 6 |
+
mod types;
|
| 7 |
+
|
| 8 |
+
pub use error::AgentGraphStoreError;
|
| 9 |
+
pub use error::AgentGraphStoreResult;
|
| 10 |
+
pub use local::LocalAgentGraphStore;
|
| 11 |
+
pub use store::AgentGraphStore;
|
| 12 |
+
pub use store::AgentGraphStoreFuture;
|
| 13 |
+
pub use types::ThreadSpawnEdgeStatus;
|
codex-rs/agent-graph-store/src/local.rs
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use codex_protocol::ThreadId;
|
| 2 |
+
use codex_state::StateRuntime;
|
| 3 |
+
use std::sync::Arc;
|
| 4 |
+
|
| 5 |
+
use crate::AgentGraphStore;
|
| 6 |
+
use crate::AgentGraphStoreError;
|
| 7 |
+
use crate::AgentGraphStoreFuture;
|
| 8 |
+
use crate::ThreadSpawnEdgeStatus;
|
| 9 |
+
|
| 10 |
+
/// SQLite-backed implementation of [`AgentGraphStore`] using an existing state runtime.
|
| 11 |
+
#[derive(Clone)]
|
| 12 |
+
pub struct LocalAgentGraphStore {
|
| 13 |
+
state_db: Arc<StateRuntime>,
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
impl std::fmt::Debug for LocalAgentGraphStore {
|
| 17 |
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
| 18 |
+
f.debug_struct("LocalAgentGraphStore")
|
| 19 |
+
.field("sqlite", self.state_db.sqlite())
|
| 20 |
+
.finish_non_exhaustive()
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
impl LocalAgentGraphStore {
|
| 25 |
+
/// Create a local graph store from an already-initialized state runtime.
|
| 26 |
+
pub fn new(state_db: Arc<StateRuntime>) -> Self {
|
| 27 |
+
Self { state_db }
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
impl AgentGraphStore for LocalAgentGraphStore {
|
| 32 |
+
fn upsert_thread_spawn_edge(
|
| 33 |
+
&self,
|
| 34 |
+
parent_thread_id: ThreadId,
|
| 35 |
+
child_thread_id: ThreadId,
|
| 36 |
+
status: ThreadSpawnEdgeStatus,
|
| 37 |
+
) -> AgentGraphStoreFuture<'_, ()> {
|
| 38 |
+
Box::pin(async move {
|
| 39 |
+
self.state_db
|
| 40 |
+
.upsert_thread_spawn_edge(
|
| 41 |
+
parent_thread_id,
|
| 42 |
+
child_thread_id,
|
| 43 |
+
to_state_status(status),
|
| 44 |
+
)
|
| 45 |
+
.await
|
| 46 |
+
.map_err(internal_error)
|
| 47 |
+
})
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
fn set_thread_spawn_edge_status(
|
| 51 |
+
&self,
|
| 52 |
+
child_thread_id: ThreadId,
|
| 53 |
+
status: ThreadSpawnEdgeStatus,
|
| 54 |
+
) -> AgentGraphStoreFuture<'_, ()> {
|
| 55 |
+
Box::pin(async move {
|
| 56 |
+
self.state_db
|
| 57 |
+
.set_thread_spawn_edge_status(child_thread_id, to_state_status(status))
|
| 58 |
+
.await
|
| 59 |
+
.map_err(internal_error)
|
| 60 |
+
})
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
fn list_thread_spawn_children(
|
| 64 |
+
&self,
|
| 65 |
+
parent_thread_id: ThreadId,
|
| 66 |
+
status_filter: Option<ThreadSpawnEdgeStatus>,
|
| 67 |
+
) -> AgentGraphStoreFuture<'_, Vec<ThreadId>> {
|
| 68 |
+
Box::pin(async move {
|
| 69 |
+
if let Some(status) = status_filter {
|
| 70 |
+
return self
|
| 71 |
+
.state_db
|
| 72 |
+
.list_thread_spawn_children_with_status(
|
| 73 |
+
parent_thread_id,
|
| 74 |
+
to_state_status(status),
|
| 75 |
+
)
|
| 76 |
+
.await
|
| 77 |
+
.map_err(internal_error);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
self.state_db
|
| 81 |
+
.list_thread_spawn_children(parent_thread_id)
|
| 82 |
+
.await
|
| 83 |
+
.map_err(internal_error)
|
| 84 |
+
})
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
fn list_thread_spawn_descendants(
|
| 88 |
+
&self,
|
| 89 |
+
root_thread_id: ThreadId,
|
| 90 |
+
status_filter: Option<ThreadSpawnEdgeStatus>,
|
| 91 |
+
) -> AgentGraphStoreFuture<'_, Vec<ThreadId>> {
|
| 92 |
+
Box::pin(async move {
|
| 93 |
+
match status_filter {
|
| 94 |
+
Some(status) => self
|
| 95 |
+
.state_db
|
| 96 |
+
.list_thread_spawn_descendants_with_status(
|
| 97 |
+
root_thread_id,
|
| 98 |
+
to_state_status(status),
|
| 99 |
+
)
|
| 100 |
+
.await
|
| 101 |
+
.map_err(internal_error),
|
| 102 |
+
None => self
|
| 103 |
+
.state_db
|
| 104 |
+
.list_thread_spawn_descendants(root_thread_id)
|
| 105 |
+
.await
|
| 106 |
+
.map_err(internal_error),
|
| 107 |
+
}
|
| 108 |
+
})
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
fn to_state_status(status: ThreadSpawnEdgeStatus) -> codex_state::DirectionalThreadSpawnEdgeStatus {
|
| 113 |
+
match status {
|
| 114 |
+
ThreadSpawnEdgeStatus::Open => codex_state::DirectionalThreadSpawnEdgeStatus::Open,
|
| 115 |
+
ThreadSpawnEdgeStatus::Closed => codex_state::DirectionalThreadSpawnEdgeStatus::Closed,
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
fn internal_error(err: impl std::fmt::Display) -> AgentGraphStoreError {
|
| 120 |
+
AgentGraphStoreError::Internal {
|
| 121 |
+
message: err.to_string(),
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
#[cfg(test)]
|
| 126 |
+
mod tests {
|
| 127 |
+
use super::*;
|
| 128 |
+
use codex_state::DirectionalThreadSpawnEdgeStatus;
|
| 129 |
+
use codex_utils_absolute_path::test_support::PathExt;
|
| 130 |
+
use pretty_assertions::assert_eq;
|
| 131 |
+
use tempfile::TempDir;
|
| 132 |
+
|
| 133 |
+
struct TestRuntime {
|
| 134 |
+
state_db: Arc<StateRuntime>,
|
| 135 |
+
_codex_home: TempDir,
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
fn thread_id(suffix: u128) -> ThreadId {
|
| 139 |
+
ThreadId::from_string(&format!("00000000-0000-0000-0000-{suffix:012}"))
|
| 140 |
+
.expect("valid thread id")
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
async fn state_runtime() -> TestRuntime {
|
| 144 |
+
let codex_home = TempDir::new().expect("tempdir should be created");
|
| 145 |
+
let state_db = StateRuntime::init(
|
| 146 |
+
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
|
| 147 |
+
"test-provider".to_string(),
|
| 148 |
+
)
|
| 149 |
+
.await
|
| 150 |
+
.expect("state db should initialize");
|
| 151 |
+
TestRuntime {
|
| 152 |
+
state_db,
|
| 153 |
+
_codex_home: codex_home,
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
#[tokio::test]
|
| 158 |
+
async fn local_store_upserts_and_lists_direct_children_with_status_filters() {
|
| 159 |
+
let fixture = state_runtime().await;
|
| 160 |
+
let state_db = fixture.state_db;
|
| 161 |
+
let store = LocalAgentGraphStore::new(state_db.clone());
|
| 162 |
+
let parent_thread_id = thread_id(/*suffix*/ 1);
|
| 163 |
+
let first_child_thread_id = thread_id(/*suffix*/ 2);
|
| 164 |
+
let second_child_thread_id = thread_id(/*suffix*/ 3);
|
| 165 |
+
|
| 166 |
+
store
|
| 167 |
+
.upsert_thread_spawn_edge(
|
| 168 |
+
parent_thread_id,
|
| 169 |
+
second_child_thread_id,
|
| 170 |
+
ThreadSpawnEdgeStatus::Closed,
|
| 171 |
+
)
|
| 172 |
+
.await
|
| 173 |
+
.expect("closed child edge should insert");
|
| 174 |
+
store
|
| 175 |
+
.upsert_thread_spawn_edge(
|
| 176 |
+
parent_thread_id,
|
| 177 |
+
first_child_thread_id,
|
| 178 |
+
ThreadSpawnEdgeStatus::Open,
|
| 179 |
+
)
|
| 180 |
+
.await
|
| 181 |
+
.expect("open child edge should insert");
|
| 182 |
+
|
| 183 |
+
let all_children = store
|
| 184 |
+
.list_thread_spawn_children(parent_thread_id, /*status_filter*/ None)
|
| 185 |
+
.await
|
| 186 |
+
.expect("all children should load");
|
| 187 |
+
assert_eq!(
|
| 188 |
+
all_children,
|
| 189 |
+
vec![first_child_thread_id, second_child_thread_id]
|
| 190 |
+
);
|
| 191 |
+
|
| 192 |
+
let open_children = store
|
| 193 |
+
.list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Open))
|
| 194 |
+
.await
|
| 195 |
+
.expect("open children should load");
|
| 196 |
+
let state_open_children = state_db
|
| 197 |
+
.list_thread_spawn_children_with_status(
|
| 198 |
+
parent_thread_id,
|
| 199 |
+
DirectionalThreadSpawnEdgeStatus::Open,
|
| 200 |
+
)
|
| 201 |
+
.await
|
| 202 |
+
.expect("state open children should load");
|
| 203 |
+
assert_eq!(open_children, state_open_children);
|
| 204 |
+
assert_eq!(open_children, vec![first_child_thread_id]);
|
| 205 |
+
|
| 206 |
+
let closed_children = store
|
| 207 |
+
.list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Closed))
|
| 208 |
+
.await
|
| 209 |
+
.expect("closed children should load");
|
| 210 |
+
assert_eq!(closed_children, vec![second_child_thread_id]);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
#[tokio::test]
|
| 214 |
+
async fn local_store_updates_edge_status() {
|
| 215 |
+
let fixture = state_runtime().await;
|
| 216 |
+
let state_db = fixture.state_db;
|
| 217 |
+
let store = LocalAgentGraphStore::new(state_db);
|
| 218 |
+
let parent_thread_id = thread_id(/*suffix*/ 10);
|
| 219 |
+
let child_thread_id = thread_id(/*suffix*/ 11);
|
| 220 |
+
|
| 221 |
+
store
|
| 222 |
+
.upsert_thread_spawn_edge(
|
| 223 |
+
parent_thread_id,
|
| 224 |
+
child_thread_id,
|
| 225 |
+
ThreadSpawnEdgeStatus::Open,
|
| 226 |
+
)
|
| 227 |
+
.await
|
| 228 |
+
.expect("child edge should insert");
|
| 229 |
+
store
|
| 230 |
+
.set_thread_spawn_edge_status(child_thread_id, ThreadSpawnEdgeStatus::Closed)
|
| 231 |
+
.await
|
| 232 |
+
.expect("child edge should close");
|
| 233 |
+
|
| 234 |
+
let open_children = store
|
| 235 |
+
.list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Open))
|
| 236 |
+
.await
|
| 237 |
+
.expect("open children should load");
|
| 238 |
+
assert_eq!(open_children, Vec::<ThreadId>::new());
|
| 239 |
+
|
| 240 |
+
let closed_children = store
|
| 241 |
+
.list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Closed))
|
| 242 |
+
.await
|
| 243 |
+
.expect("closed children should load");
|
| 244 |
+
assert_eq!(closed_children, vec![child_thread_id]);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
#[tokio::test]
|
| 248 |
+
async fn local_store_lists_descendants_breadth_first_with_status_filters() {
|
| 249 |
+
let fixture = state_runtime().await;
|
| 250 |
+
let state_db = fixture.state_db;
|
| 251 |
+
let store = LocalAgentGraphStore::new(state_db.clone());
|
| 252 |
+
let root_thread_id = thread_id(/*suffix*/ 20);
|
| 253 |
+
let later_child_thread_id = thread_id(/*suffix*/ 22);
|
| 254 |
+
let earlier_child_thread_id = thread_id(/*suffix*/ 21);
|
| 255 |
+
let closed_grandchild_thread_id = thread_id(/*suffix*/ 23);
|
| 256 |
+
let open_grandchild_thread_id = thread_id(/*suffix*/ 24);
|
| 257 |
+
let closed_child_thread_id = thread_id(/*suffix*/ 25);
|
| 258 |
+
let closed_great_grandchild_thread_id = thread_id(/*suffix*/ 26);
|
| 259 |
+
|
| 260 |
+
for (parent_thread_id, child_thread_id, status) in [
|
| 261 |
+
(
|
| 262 |
+
root_thread_id,
|
| 263 |
+
later_child_thread_id,
|
| 264 |
+
ThreadSpawnEdgeStatus::Open,
|
| 265 |
+
),
|
| 266 |
+
(
|
| 267 |
+
root_thread_id,
|
| 268 |
+
earlier_child_thread_id,
|
| 269 |
+
ThreadSpawnEdgeStatus::Open,
|
| 270 |
+
),
|
| 271 |
+
(
|
| 272 |
+
earlier_child_thread_id,
|
| 273 |
+
open_grandchild_thread_id,
|
| 274 |
+
ThreadSpawnEdgeStatus::Open,
|
| 275 |
+
),
|
| 276 |
+
(
|
| 277 |
+
later_child_thread_id,
|
| 278 |
+
closed_grandchild_thread_id,
|
| 279 |
+
ThreadSpawnEdgeStatus::Closed,
|
| 280 |
+
),
|
| 281 |
+
(
|
| 282 |
+
root_thread_id,
|
| 283 |
+
closed_child_thread_id,
|
| 284 |
+
ThreadSpawnEdgeStatus::Closed,
|
| 285 |
+
),
|
| 286 |
+
(
|
| 287 |
+
closed_child_thread_id,
|
| 288 |
+
closed_great_grandchild_thread_id,
|
| 289 |
+
ThreadSpawnEdgeStatus::Closed,
|
| 290 |
+
),
|
| 291 |
+
] {
|
| 292 |
+
store
|
| 293 |
+
.upsert_thread_spawn_edge(parent_thread_id, child_thread_id, status)
|
| 294 |
+
.await
|
| 295 |
+
.expect("edge should insert");
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
let all_descendants = store
|
| 299 |
+
.list_thread_spawn_descendants(root_thread_id, /*status_filter*/ None)
|
| 300 |
+
.await
|
| 301 |
+
.expect("all descendants should load");
|
| 302 |
+
assert_eq!(
|
| 303 |
+
all_descendants,
|
| 304 |
+
vec![
|
| 305 |
+
earlier_child_thread_id,
|
| 306 |
+
later_child_thread_id,
|
| 307 |
+
closed_child_thread_id,
|
| 308 |
+
closed_grandchild_thread_id,
|
| 309 |
+
open_grandchild_thread_id,
|
| 310 |
+
closed_great_grandchild_thread_id,
|
| 311 |
+
]
|
| 312 |
+
);
|
| 313 |
+
|
| 314 |
+
let open_descendants = store
|
| 315 |
+
.list_thread_spawn_descendants(root_thread_id, Some(ThreadSpawnEdgeStatus::Open))
|
| 316 |
+
.await
|
| 317 |
+
.expect("open descendants should load");
|
| 318 |
+
let state_open_descendants = state_db
|
| 319 |
+
.list_thread_spawn_descendants_with_status(
|
| 320 |
+
root_thread_id,
|
| 321 |
+
DirectionalThreadSpawnEdgeStatus::Open,
|
| 322 |
+
)
|
| 323 |
+
.await
|
| 324 |
+
.expect("state open descendants should load");
|
| 325 |
+
assert_eq!(open_descendants, state_open_descendants);
|
| 326 |
+
assert_eq!(
|
| 327 |
+
open_descendants,
|
| 328 |
+
vec![
|
| 329 |
+
earlier_child_thread_id,
|
| 330 |
+
later_child_thread_id,
|
| 331 |
+
open_grandchild_thread_id,
|
| 332 |
+
]
|
| 333 |
+
);
|
| 334 |
+
|
| 335 |
+
let closed_descendants = store
|
| 336 |
+
.list_thread_spawn_descendants(root_thread_id, Some(ThreadSpawnEdgeStatus::Closed))
|
| 337 |
+
.await
|
| 338 |
+
.expect("closed descendants should load");
|
| 339 |
+
assert_eq!(
|
| 340 |
+
closed_descendants,
|
| 341 |
+
vec![closed_child_thread_id, closed_great_grandchild_thread_id]
|
| 342 |
+
);
|
| 343 |
+
}
|
| 344 |
+
}
|
codex-rs/agent-graph-store/src/store.rs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::future::Future;
|
| 2 |
+
use std::pin::Pin;
|
| 3 |
+
|
| 4 |
+
use codex_protocol::ThreadId;
|
| 5 |
+
|
| 6 |
+
use crate::AgentGraphStoreResult;
|
| 7 |
+
use crate::ThreadSpawnEdgeStatus;
|
| 8 |
+
|
| 9 |
+
/// Future returned by [`AgentGraphStore`] operations.
|
| 10 |
+
pub type AgentGraphStoreFuture<'a, T> =
|
| 11 |
+
Pin<Box<dyn Future<Output = AgentGraphStoreResult<T>> + Send + 'a>>;
|
| 12 |
+
|
| 13 |
+
/// Storage-neutral boundary for persisted thread-spawn parent/child topology.
|
| 14 |
+
///
|
| 15 |
+
/// Implementations are expected to return stable ordering for list methods so callers can merge
|
| 16 |
+
/// persisted graph state with live in-memory state without introducing nondeterministic output.
|
| 17 |
+
pub trait AgentGraphStore: Send + Sync {
|
| 18 |
+
/// Insert or replace the directional parent/child edge for a spawned thread.
|
| 19 |
+
///
|
| 20 |
+
/// `child_thread_id` has at most one persisted parent. Re-inserting the same child should
|
| 21 |
+
/// update both the parent and status to match the supplied values.
|
| 22 |
+
fn upsert_thread_spawn_edge(
|
| 23 |
+
&self,
|
| 24 |
+
parent_thread_id: ThreadId,
|
| 25 |
+
child_thread_id: ThreadId,
|
| 26 |
+
status: ThreadSpawnEdgeStatus,
|
| 27 |
+
) -> AgentGraphStoreFuture<'_, ()>;
|
| 28 |
+
|
| 29 |
+
/// Update the persisted lifecycle status of a spawned thread's incoming edge.
|
| 30 |
+
///
|
| 31 |
+
/// Implementations should treat missing children as a successful no-op.
|
| 32 |
+
fn set_thread_spawn_edge_status(
|
| 33 |
+
&self,
|
| 34 |
+
child_thread_id: ThreadId,
|
| 35 |
+
status: ThreadSpawnEdgeStatus,
|
| 36 |
+
) -> AgentGraphStoreFuture<'_, ()>;
|
| 37 |
+
|
| 38 |
+
/// List direct spawned children of a parent thread.
|
| 39 |
+
///
|
| 40 |
+
/// When `status_filter` is `Some`, only child edges with that exact status are returned. When
|
| 41 |
+
/// it is `None`, all direct child edges are returned regardless of status, including statuses
|
| 42 |
+
/// that may be added by a future store implementation.
|
| 43 |
+
fn list_thread_spawn_children(
|
| 44 |
+
&self,
|
| 45 |
+
parent_thread_id: ThreadId,
|
| 46 |
+
status_filter: Option<ThreadSpawnEdgeStatus>,
|
| 47 |
+
) -> AgentGraphStoreFuture<'_, Vec<ThreadId>>;
|
| 48 |
+
|
| 49 |
+
/// List spawned descendants breadth-first by depth, then by thread id.
|
| 50 |
+
///
|
| 51 |
+
/// `status_filter` is applied to every traversed edge, not just to the returned descendants.
|
| 52 |
+
/// For example, `Some(Open)` walks only open edges, so descendants under a closed edge are not
|
| 53 |
+
/// included even if their own incoming edge is open. `None` walks and returns every persisted
|
| 54 |
+
/// edge regardless of status.
|
| 55 |
+
fn list_thread_spawn_descendants(
|
| 56 |
+
&self,
|
| 57 |
+
root_thread_id: ThreadId,
|
| 58 |
+
status_filter: Option<ThreadSpawnEdgeStatus>,
|
| 59 |
+
) -> AgentGraphStoreFuture<'_, Vec<ThreadId>>;
|
| 60 |
+
}
|
codex-rs/agent-graph-store/src/types.rs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use serde::Deserialize;
|
| 2 |
+
use serde::Serialize;
|
| 3 |
+
|
| 4 |
+
/// Lifecycle status attached to a directional thread-spawn edge.
|
| 5 |
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
| 6 |
+
#[serde(rename_all = "snake_case")]
|
| 7 |
+
pub enum ThreadSpawnEdgeStatus {
|
| 8 |
+
/// The child thread is still live or resumable as an open spawned agent.
|
| 9 |
+
Open,
|
| 10 |
+
/// The child thread has been closed from the parent/child graph's perspective.
|
| 11 |
+
Closed,
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
#[cfg(test)]
|
| 15 |
+
mod tests {
|
| 16 |
+
use super::*;
|
| 17 |
+
use pretty_assertions::assert_eq;
|
| 18 |
+
|
| 19 |
+
#[test]
|
| 20 |
+
fn thread_spawn_edge_status_serializes_as_snake_case() {
|
| 21 |
+
assert_eq!(
|
| 22 |
+
serde_json::to_string(&ThreadSpawnEdgeStatus::Open)
|
| 23 |
+
.expect("open status should serialize"),
|
| 24 |
+
"\"open\""
|
| 25 |
+
);
|
| 26 |
+
assert_eq!(
|
| 27 |
+
serde_json::to_string(&ThreadSpawnEdgeStatus::Closed)
|
| 28 |
+
.expect("closed status should serialize"),
|
| 29 |
+
"\"closed\""
|
| 30 |
+
);
|
| 31 |
+
assert_eq!(
|
| 32 |
+
serde_json::from_str::<ThreadSpawnEdgeStatus>("\"open\"")
|
| 33 |
+
.expect("open status should deserialize"),
|
| 34 |
+
ThreadSpawnEdgeStatus::Open
|
| 35 |
+
);
|
| 36 |
+
assert_eq!(
|
| 37 |
+
serde_json::from_str::<ThreadSpawnEdgeStatus>("\"closed\"")
|
| 38 |
+
.expect("closed status should deserialize"),
|
| 39 |
+
ThreadSpawnEdgeStatus::Closed
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
}
|
codex-rs/apply-patch/src/file_update.rs
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use codex_exec_server::ExecutorFileSystem;
|
| 2 |
+
use codex_exec_server::FileSystemSandboxContext;
|
| 3 |
+
use codex_exec_server::ReadFileOptions;
|
| 4 |
+
use codex_utils_path_uri::PathUri;
|
| 5 |
+
use similar::TextDiff;
|
| 6 |
+
|
| 7 |
+
use crate::ApplyPatchError;
|
| 8 |
+
use crate::ApplyPatchFileUpdateMode;
|
| 9 |
+
use crate::IoError;
|
| 10 |
+
use crate::UpdateFileChunk;
|
| 11 |
+
use crate::seek_sequence;
|
| 12 |
+
use crate::text_file::Replacement;
|
| 13 |
+
use crate::text_file::SourceFile;
|
| 14 |
+
|
| 15 |
+
#[cfg(test)]
|
| 16 |
+
#[path = "file_update_tests.rs"]
|
| 17 |
+
mod tests;
|
| 18 |
+
|
| 19 |
+
pub(crate) struct AppliedPatch {
|
| 20 |
+
pub(crate) original_contents: String,
|
| 21 |
+
pub(crate) new_contents: String,
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/// Return *only* the new file contents (joined into a single `String`) after
|
| 25 |
+
/// applying the chunks to the file at `path`.
|
| 26 |
+
pub(crate) async fn derive_new_contents_from_chunks(
|
| 27 |
+
path: &PathUri,
|
| 28 |
+
chunks: &[UpdateFileChunk],
|
| 29 |
+
update_file_mode: ApplyPatchFileUpdateMode,
|
| 30 |
+
fs: &dyn ExecutorFileSystem,
|
| 31 |
+
follow_symlinks: bool,
|
| 32 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 33 |
+
) -> std::result::Result<AppliedPatch, ApplyPatchError> {
|
| 34 |
+
let original_contents = fs
|
| 35 |
+
.read_file_text(path, ReadFileOptions { follow_symlinks }, sandbox)
|
| 36 |
+
.await
|
| 37 |
+
.map_err(|err| {
|
| 38 |
+
ApplyPatchError::IoError(IoError {
|
| 39 |
+
context: format!(
|
| 40 |
+
"Failed to read file to update {}",
|
| 41 |
+
path.inferred_native_path_string()
|
| 42 |
+
),
|
| 43 |
+
source: err,
|
| 44 |
+
})
|
| 45 |
+
})?;
|
| 46 |
+
|
| 47 |
+
let path_text = path.inferred_native_path_string();
|
| 48 |
+
let new_contents = match update_file_mode {
|
| 49 |
+
ApplyPatchFileUpdateMode::NormalizeToLf => {
|
| 50 |
+
let mut original_lines = original_contents
|
| 51 |
+
.split('\n')
|
| 52 |
+
.map(String::from)
|
| 53 |
+
.collect::<Vec<_>>();
|
| 54 |
+
|
| 55 |
+
// Drop the trailing empty element that results from the final newline so
|
| 56 |
+
// that line counts match the behaviour of standard `diff`.
|
| 57 |
+
if original_lines.last().is_some_and(String::is_empty) {
|
| 58 |
+
original_lines.pop();
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
let replacements =
|
| 62 |
+
compute_replacements(&original_lines, &path_text, chunks, update_file_mode)?;
|
| 63 |
+
let mut new_lines = apply_replacements(original_lines, &replacements);
|
| 64 |
+
if !new_lines.last().is_some_and(String::is_empty) {
|
| 65 |
+
new_lines.push(String::new());
|
| 66 |
+
}
|
| 67 |
+
new_lines.join("\n")
|
| 68 |
+
}
|
| 69 |
+
ApplyPatchFileUpdateMode::PreserveLineEndings => {
|
| 70 |
+
let mut source_file = SourceFile::parse(&original_contents);
|
| 71 |
+
let original_lines = source_file.line_texts();
|
| 72 |
+
let replacements =
|
| 73 |
+
compute_replacements(&original_lines, &path_text, chunks, update_file_mode)?;
|
| 74 |
+
source_file.apply_replacements(&replacements);
|
| 75 |
+
source_file.into_contents()
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
Ok(AppliedPatch {
|
| 79 |
+
original_contents,
|
| 80 |
+
new_contents,
|
| 81 |
+
})
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/// Compute a list of replacements needed to transform `original_lines` into the
|
| 85 |
+
/// new lines, given the patch `chunks`. Each replacement is returned as
|
| 86 |
+
/// `(start_index, old_len, new_lines)`.
|
| 87 |
+
fn compute_replacements(
|
| 88 |
+
original_lines: &[String],
|
| 89 |
+
path: &str,
|
| 90 |
+
chunks: &[UpdateFileChunk],
|
| 91 |
+
update_file_mode: ApplyPatchFileUpdateMode,
|
| 92 |
+
) -> std::result::Result<Vec<Replacement>, ApplyPatchError> {
|
| 93 |
+
let mut replacements: Vec<Replacement> = Vec::new();
|
| 94 |
+
let mut line_index: usize = 0;
|
| 95 |
+
|
| 96 |
+
for chunk in chunks {
|
| 97 |
+
// If a chunk has a `change_context`, we use seek_sequence to find it, then
|
| 98 |
+
// adjust our `line_index` to continue from there.
|
| 99 |
+
if let Some(ctx_line) = &chunk.change_context {
|
| 100 |
+
if let Some(idx) = seek_sequence::seek_sequence(
|
| 101 |
+
original_lines,
|
| 102 |
+
std::slice::from_ref(ctx_line),
|
| 103 |
+
line_index,
|
| 104 |
+
/*eof*/ false,
|
| 105 |
+
update_file_mode,
|
| 106 |
+
) {
|
| 107 |
+
line_index = idx + 1;
|
| 108 |
+
} else {
|
| 109 |
+
return Err(ApplyPatchError::ComputeReplacements(format!(
|
| 110 |
+
"Failed to find context '{ctx_line}' in {path}"
|
| 111 |
+
)));
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
if chunk.old_lines.is_empty() {
|
| 116 |
+
// Preserve the legacy split representation's handling of a final
|
| 117 |
+
// empty line. `SourceFile` only exposes real source lines, so its
|
| 118 |
+
// insertion point is always after the final line.
|
| 119 |
+
let insertion_idx = match update_file_mode {
|
| 120 |
+
ApplyPatchFileUpdateMode::NormalizeToLf => {
|
| 121 |
+
if original_lines.last().is_some_and(String::is_empty) {
|
| 122 |
+
original_lines.len() - 1
|
| 123 |
+
} else {
|
| 124 |
+
original_lines.len()
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
ApplyPatchFileUpdateMode::PreserveLineEndings => original_lines.len(),
|
| 128 |
+
};
|
| 129 |
+
replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
|
| 130 |
+
continue;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// Otherwise, try to match the existing lines in the file with the old lines
|
| 134 |
+
// from the chunk. If found, schedule that region for replacement.
|
| 135 |
+
// Attempt to locate the `old_lines` verbatim within the file. In many
|
| 136 |
+
// real‑world diffs the last element of `old_lines` is an *empty* string
|
| 137 |
+
// representing the terminating newline of the region being replaced.
|
| 138 |
+
// This sentinel is not present in `original_lines` because `SourceFile`
|
| 139 |
+
// stores the terminator on the preceding line rather than as an extra
|
| 140 |
+
// trailing element. If a direct search fails and the pattern ends with
|
| 141 |
+
// an empty string, retry without that final element so modifications
|
| 142 |
+
// touching the end‑of‑file can be located reliably.
|
| 143 |
+
|
| 144 |
+
let mut pattern: &[String] = &chunk.old_lines;
|
| 145 |
+
let mut found = seek_sequence::seek_sequence(
|
| 146 |
+
original_lines,
|
| 147 |
+
pattern,
|
| 148 |
+
line_index,
|
| 149 |
+
chunk.is_end_of_file,
|
| 150 |
+
update_file_mode,
|
| 151 |
+
);
|
| 152 |
+
|
| 153 |
+
let mut new_slice: &[String] = &chunk.new_lines;
|
| 154 |
+
|
| 155 |
+
if found.is_none() && pattern.last().is_some_and(String::is_empty) {
|
| 156 |
+
// Retry without the trailing empty line which represents the final
|
| 157 |
+
// newline in the file.
|
| 158 |
+
pattern = &pattern[..pattern.len() - 1];
|
| 159 |
+
if new_slice.last().is_some_and(String::is_empty) {
|
| 160 |
+
new_slice = &new_slice[..new_slice.len() - 1];
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
found = seek_sequence::seek_sequence(
|
| 164 |
+
original_lines,
|
| 165 |
+
pattern,
|
| 166 |
+
line_index,
|
| 167 |
+
chunk.is_end_of_file,
|
| 168 |
+
update_file_mode,
|
| 169 |
+
);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
if let Some(start_idx) = found {
|
| 173 |
+
match update_file_mode {
|
| 174 |
+
ApplyPatchFileUpdateMode::NormalizeToLf => {
|
| 175 |
+
replacements.push((start_idx, pattern.len(), new_slice.to_vec()));
|
| 176 |
+
}
|
| 177 |
+
ApplyPatchFileUpdateMode::PreserveLineEndings => {
|
| 178 |
+
// Context lines occur in both sides of a patch chunk. Keep those
|
| 179 |
+
// original lines in place so their exact contents and terminators
|
| 180 |
+
// survive, especially when the file has mixed line endings.
|
| 181 |
+
let mut old_start = 0;
|
| 182 |
+
let mut new_start = 0;
|
| 183 |
+
for &(old_context, new_context) in &chunk.context_line_indices {
|
| 184 |
+
// A trailing empty context line can be removed from `pattern`
|
| 185 |
+
// and `new_slice` above when it represents the final newline.
|
| 186 |
+
if old_context >= pattern.len() || new_context >= new_slice.len() {
|
| 187 |
+
break;
|
| 188 |
+
}
|
| 189 |
+
if old_start != old_context || new_start != new_context {
|
| 190 |
+
replacements.push((
|
| 191 |
+
start_idx + old_start,
|
| 192 |
+
old_context - old_start,
|
| 193 |
+
new_slice[new_start..new_context].to_vec(),
|
| 194 |
+
));
|
| 195 |
+
}
|
| 196 |
+
old_start = old_context + 1;
|
| 197 |
+
new_start = new_context + 1;
|
| 198 |
+
}
|
| 199 |
+
if old_start != pattern.len() || new_start != new_slice.len() {
|
| 200 |
+
replacements.push((
|
| 201 |
+
start_idx + old_start,
|
| 202 |
+
pattern.len() - old_start,
|
| 203 |
+
new_slice[new_start..].to_vec(),
|
| 204 |
+
));
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
line_index = start_idx + pattern.len();
|
| 209 |
+
} else {
|
| 210 |
+
return Err(ApplyPatchError::ComputeReplacements(format!(
|
| 211 |
+
"Failed to find expected lines in {}:\n{}",
|
| 212 |
+
path,
|
| 213 |
+
chunk.old_lines.join("\n"),
|
| 214 |
+
)));
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
replacements.sort_by_key(|(index, _, _)| *index);
|
| 219 |
+
|
| 220 |
+
Ok(replacements)
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
/// Apply the `(start_index, old_len, new_lines)` replacements to `original_lines`,
|
| 224 |
+
/// returning the modified file contents as a vector of lines.
|
| 225 |
+
fn apply_replacements(mut lines: Vec<String>, replacements: &[Replacement]) -> Vec<String> {
|
| 226 |
+
// We must apply replacements in descending order so that earlier replacements
|
| 227 |
+
// don't shift the positions of later ones.
|
| 228 |
+
for (start_idx, old_len, new_segment) in replacements.iter().rev() {
|
| 229 |
+
let start_idx = *start_idx;
|
| 230 |
+
let old_len = *old_len;
|
| 231 |
+
|
| 232 |
+
// Remove old lines.
|
| 233 |
+
for _ in 0..old_len {
|
| 234 |
+
if start_idx < lines.len() {
|
| 235 |
+
lines.remove(start_idx);
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// Insert new lines.
|
| 240 |
+
for (offset, new_line) in new_segment.iter().enumerate() {
|
| 241 |
+
lines.insert(start_idx + offset, new_line.clone());
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
lines
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/// Intended result of a file update for apply_patch.
|
| 249 |
+
#[derive(Debug, Eq, PartialEq)]
|
| 250 |
+
pub struct ApplyPatchFileUpdate {
|
| 251 |
+
pub(crate) unified_diff: String,
|
| 252 |
+
pub(crate) original_content: String,
|
| 253 |
+
pub(crate) content: String,
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
pub async fn unified_diff_from_chunks(
|
| 257 |
+
path: &PathUri,
|
| 258 |
+
chunks: &[UpdateFileChunk],
|
| 259 |
+
fs: &dyn ExecutorFileSystem,
|
| 260 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 261 |
+
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
|
| 262 |
+
unified_diff_from_chunks_with_mode(
|
| 263 |
+
path,
|
| 264 |
+
chunks,
|
| 265 |
+
ApplyPatchFileUpdateMode::default(),
|
| 266 |
+
fs,
|
| 267 |
+
sandbox,
|
| 268 |
+
)
|
| 269 |
+
.await
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
pub(crate) async fn unified_diff_from_chunks_with_mode(
|
| 273 |
+
path: &PathUri,
|
| 274 |
+
chunks: &[UpdateFileChunk],
|
| 275 |
+
update_file_mode: ApplyPatchFileUpdateMode,
|
| 276 |
+
fs: &dyn ExecutorFileSystem,
|
| 277 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 278 |
+
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
|
| 279 |
+
unified_diff_from_chunks_with_context_and_mode(
|
| 280 |
+
path,
|
| 281 |
+
chunks,
|
| 282 |
+
/*context*/ 1,
|
| 283 |
+
update_file_mode,
|
| 284 |
+
fs,
|
| 285 |
+
sandbox,
|
| 286 |
+
)
|
| 287 |
+
.await
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
pub async fn unified_diff_from_chunks_with_context(
|
| 291 |
+
path: &PathUri,
|
| 292 |
+
chunks: &[UpdateFileChunk],
|
| 293 |
+
context: usize,
|
| 294 |
+
fs: &dyn ExecutorFileSystem,
|
| 295 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 296 |
+
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
|
| 297 |
+
unified_diff_from_chunks_with_context_and_mode(
|
| 298 |
+
path,
|
| 299 |
+
chunks,
|
| 300 |
+
context,
|
| 301 |
+
ApplyPatchFileUpdateMode::default(),
|
| 302 |
+
fs,
|
| 303 |
+
sandbox,
|
| 304 |
+
)
|
| 305 |
+
.await
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
async fn unified_diff_from_chunks_with_context_and_mode(
|
| 309 |
+
path: &PathUri,
|
| 310 |
+
chunks: &[UpdateFileChunk],
|
| 311 |
+
context: usize,
|
| 312 |
+
update_file_mode: ApplyPatchFileUpdateMode,
|
| 313 |
+
fs: &dyn ExecutorFileSystem,
|
| 314 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 315 |
+
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
|
| 316 |
+
let AppliedPatch {
|
| 317 |
+
original_contents,
|
| 318 |
+
new_contents,
|
| 319 |
+
} = derive_new_contents_from_chunks(
|
| 320 |
+
path,
|
| 321 |
+
chunks,
|
| 322 |
+
update_file_mode,
|
| 323 |
+
fs,
|
| 324 |
+
/*follow_symlinks*/ true,
|
| 325 |
+
sandbox,
|
| 326 |
+
)
|
| 327 |
+
.await?;
|
| 328 |
+
let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
|
| 329 |
+
let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
|
| 330 |
+
Ok(ApplyPatchFileUpdate {
|
| 331 |
+
unified_diff,
|
| 332 |
+
original_content: original_contents,
|
| 333 |
+
content: new_contents,
|
| 334 |
+
})
|
| 335 |
+
}
|
codex-rs/apply-patch/src/file_update_tests.rs
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use super::*;
|
| 2 |
+
use crate::Hunk;
|
| 3 |
+
use crate::apply_patch;
|
| 4 |
+
use crate::parse_patch;
|
| 5 |
+
use codex_exec_server::LOCAL_FS;
|
| 6 |
+
use codex_utils_path_uri::PathUri;
|
| 7 |
+
use pretty_assertions::assert_eq;
|
| 8 |
+
use std::fs;
|
| 9 |
+
use tempfile::tempdir;
|
| 10 |
+
|
| 11 |
+
fn wrap_patch(body: &str) -> String {
|
| 12 |
+
format!("*** Begin Patch\n{body}\n*** End Patch")
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
#[tokio::test]
|
| 16 |
+
async fn test_unified_diff() {
|
| 17 |
+
// Start with a file containing four lines.
|
| 18 |
+
let dir = tempdir().unwrap();
|
| 19 |
+
let path = dir.path().join("multi.txt");
|
| 20 |
+
fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
|
| 21 |
+
let patch = wrap_patch(&format!(
|
| 22 |
+
r#"*** Update File: {}
|
| 23 |
+
@@
|
| 24 |
+
foo
|
| 25 |
+
-bar
|
| 26 |
+
+BAR
|
| 27 |
+
@@
|
| 28 |
+
baz
|
| 29 |
+
-qux
|
| 30 |
+
+QUX"#,
|
| 31 |
+
path.display()
|
| 32 |
+
));
|
| 33 |
+
let patch = parse_patch(&patch).unwrap();
|
| 34 |
+
|
| 35 |
+
let update_file_chunks = match patch.hunks.as_slice() {
|
| 36 |
+
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
| 37 |
+
_ => panic!("Expected a single UpdateFile hunk"),
|
| 38 |
+
};
|
| 39 |
+
let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
|
| 40 |
+
let diff = unified_diff_from_chunks(
|
| 41 |
+
&path_uri,
|
| 42 |
+
update_file_chunks,
|
| 43 |
+
LOCAL_FS.as_ref(),
|
| 44 |
+
/*sandbox*/ None,
|
| 45 |
+
)
|
| 46 |
+
.await
|
| 47 |
+
.unwrap();
|
| 48 |
+
let expected_diff = r#"@@ -1,4 +1,4 @@
|
| 49 |
+
foo
|
| 50 |
+
-bar
|
| 51 |
+
+BAR
|
| 52 |
+
baz
|
| 53 |
+
-qux
|
| 54 |
+
+QUX
|
| 55 |
+
"#;
|
| 56 |
+
let expected = ApplyPatchFileUpdate {
|
| 57 |
+
unified_diff: expected_diff.to_string(),
|
| 58 |
+
original_content: "foo\nbar\nbaz\nqux\n".to_string(),
|
| 59 |
+
content: "foo\nBAR\nbaz\nQUX\n".to_string(),
|
| 60 |
+
};
|
| 61 |
+
assert_eq!(expected, diff);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
#[tokio::test]
|
| 65 |
+
async fn test_unified_diff_first_line_replacement() {
|
| 66 |
+
// Replace the very first line of the file.
|
| 67 |
+
let dir = tempdir().unwrap();
|
| 68 |
+
let path = dir.path().join("first.txt");
|
| 69 |
+
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
|
| 70 |
+
|
| 71 |
+
let patch = wrap_patch(&format!(
|
| 72 |
+
r#"*** Update File: {}
|
| 73 |
+
@@
|
| 74 |
+
-foo
|
| 75 |
+
+FOO
|
| 76 |
+
bar
|
| 77 |
+
"#,
|
| 78 |
+
path.display()
|
| 79 |
+
));
|
| 80 |
+
|
| 81 |
+
let patch = parse_patch(&patch).unwrap();
|
| 82 |
+
let chunks = match patch.hunks.as_slice() {
|
| 83 |
+
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
| 84 |
+
_ => panic!("Expected a single UpdateFile hunk"),
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
let resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path");
|
| 88 |
+
let diff = unified_diff_from_chunks(
|
| 89 |
+
&resolved_path,
|
| 90 |
+
chunks,
|
| 91 |
+
LOCAL_FS.as_ref(),
|
| 92 |
+
/*sandbox*/ None,
|
| 93 |
+
)
|
| 94 |
+
.await
|
| 95 |
+
.unwrap();
|
| 96 |
+
let expected_diff = r#"@@ -1,2 +1,2 @@
|
| 97 |
+
-foo
|
| 98 |
+
+FOO
|
| 99 |
+
bar
|
| 100 |
+
"#;
|
| 101 |
+
let expected = ApplyPatchFileUpdate {
|
| 102 |
+
unified_diff: expected_diff.to_string(),
|
| 103 |
+
original_content: "foo\nbar\nbaz\n".to_string(),
|
| 104 |
+
content: "FOO\nbar\nbaz\n".to_string(),
|
| 105 |
+
};
|
| 106 |
+
assert_eq!(expected, diff);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
#[tokio::test]
|
| 110 |
+
async fn test_unified_diff_last_line_replacement() {
|
| 111 |
+
// Replace the very last line of the file.
|
| 112 |
+
let dir = tempdir().unwrap();
|
| 113 |
+
let path = dir.path().join("last.txt");
|
| 114 |
+
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
|
| 115 |
+
|
| 116 |
+
let patch = wrap_patch(&format!(
|
| 117 |
+
r#"*** Update File: {}
|
| 118 |
+
@@
|
| 119 |
+
foo
|
| 120 |
+
bar
|
| 121 |
+
-baz
|
| 122 |
+
+BAZ
|
| 123 |
+
"#,
|
| 124 |
+
path.display()
|
| 125 |
+
));
|
| 126 |
+
|
| 127 |
+
let patch = parse_patch(&patch).unwrap();
|
| 128 |
+
let chunks = match patch.hunks.as_slice() {
|
| 129 |
+
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
| 130 |
+
_ => panic!("Expected a single UpdateFile hunk"),
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
let resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path");
|
| 134 |
+
let diff = unified_diff_from_chunks(
|
| 135 |
+
&resolved_path,
|
| 136 |
+
chunks,
|
| 137 |
+
LOCAL_FS.as_ref(),
|
| 138 |
+
/*sandbox*/ None,
|
| 139 |
+
)
|
| 140 |
+
.await
|
| 141 |
+
.unwrap();
|
| 142 |
+
let expected_diff = r#"@@ -2,2 +2,2 @@
|
| 143 |
+
bar
|
| 144 |
+
-baz
|
| 145 |
+
+BAZ
|
| 146 |
+
"#;
|
| 147 |
+
let expected = ApplyPatchFileUpdate {
|
| 148 |
+
unified_diff: expected_diff.to_string(),
|
| 149 |
+
original_content: "foo\nbar\nbaz\n".to_string(),
|
| 150 |
+
content: "foo\nbar\nBAZ\n".to_string(),
|
| 151 |
+
};
|
| 152 |
+
assert_eq!(expected, diff);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
#[tokio::test]
|
| 156 |
+
async fn test_unified_diff_insert_at_eof() {
|
| 157 |
+
// Insert a new line at end-of-file.
|
| 158 |
+
let dir = tempdir().unwrap();
|
| 159 |
+
let path = dir.path().join("insert.txt");
|
| 160 |
+
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
|
| 161 |
+
|
| 162 |
+
let patch = wrap_patch(&format!(
|
| 163 |
+
r#"*** Update File: {}
|
| 164 |
+
@@
|
| 165 |
+
+quux
|
| 166 |
+
*** End of File
|
| 167 |
+
"#,
|
| 168 |
+
path.display()
|
| 169 |
+
));
|
| 170 |
+
|
| 171 |
+
let patch = parse_patch(&patch).unwrap();
|
| 172 |
+
let chunks = match patch.hunks.as_slice() {
|
| 173 |
+
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
| 174 |
+
_ => panic!("Expected a single UpdateFile hunk"),
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
|
| 178 |
+
let diff =
|
| 179 |
+
unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
| 180 |
+
.await
|
| 181 |
+
.unwrap();
|
| 182 |
+
let expected_diff = r#"@@ -3 +3,2 @@
|
| 183 |
+
baz
|
| 184 |
+
+quux
|
| 185 |
+
"#;
|
| 186 |
+
let expected = ApplyPatchFileUpdate {
|
| 187 |
+
unified_diff: expected_diff.to_string(),
|
| 188 |
+
original_content: "foo\nbar\nbaz\n".to_string(),
|
| 189 |
+
content: "foo\nbar\nbaz\nquux\n".to_string(),
|
| 190 |
+
};
|
| 191 |
+
assert_eq!(expected, diff);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
#[tokio::test]
|
| 195 |
+
async fn test_unified_diff_interleaved_changes() {
|
| 196 |
+
// Original file with six lines.
|
| 197 |
+
let dir = tempdir().unwrap();
|
| 198 |
+
let path = dir.path().join("interleaved.txt");
|
| 199 |
+
fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
|
| 200 |
+
|
| 201 |
+
// Patch replaces two separate lines and appends a new one at EOF using
|
| 202 |
+
// three distinct chunks.
|
| 203 |
+
let patch_body = format!(
|
| 204 |
+
r#"*** Update File: {}
|
| 205 |
+
@@
|
| 206 |
+
a
|
| 207 |
+
-b
|
| 208 |
+
+B
|
| 209 |
+
@@
|
| 210 |
+
d
|
| 211 |
+
-e
|
| 212 |
+
+E
|
| 213 |
+
@@
|
| 214 |
+
f
|
| 215 |
+
+g
|
| 216 |
+
*** End of File"#,
|
| 217 |
+
path.display()
|
| 218 |
+
);
|
| 219 |
+
let patch = wrap_patch(&patch_body);
|
| 220 |
+
|
| 221 |
+
// Extract chunks then build the unified diff.
|
| 222 |
+
let parsed = parse_patch(&patch).unwrap();
|
| 223 |
+
let chunks = match parsed.hunks.as_slice() {
|
| 224 |
+
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
| 225 |
+
_ => panic!("Expected a single UpdateFile hunk"),
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
|
| 229 |
+
let diff =
|
| 230 |
+
unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
| 231 |
+
.await
|
| 232 |
+
.unwrap();
|
| 233 |
+
|
| 234 |
+
let expected_diff = r#"@@ -1,6 +1,7 @@
|
| 235 |
+
a
|
| 236 |
+
-b
|
| 237 |
+
+B
|
| 238 |
+
c
|
| 239 |
+
d
|
| 240 |
+
-e
|
| 241 |
+
+E
|
| 242 |
+
f
|
| 243 |
+
+g
|
| 244 |
+
"#;
|
| 245 |
+
|
| 246 |
+
let expected = ApplyPatchFileUpdate {
|
| 247 |
+
unified_diff: expected_diff.to_string(),
|
| 248 |
+
original_content: "a\nb\nc\nd\ne\nf\n".to_string(),
|
| 249 |
+
content: "a\nB\nc\nd\nE\nf\ng\n".to_string(),
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
assert_eq!(expected, diff);
|
| 253 |
+
|
| 254 |
+
let mut stdout = Vec::new();
|
| 255 |
+
let mut stderr = Vec::new();
|
| 256 |
+
apply_patch(
|
| 257 |
+
&patch,
|
| 258 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 259 |
+
&mut stdout,
|
| 260 |
+
&mut stderr,
|
| 261 |
+
LOCAL_FS.as_ref(),
|
| 262 |
+
/*sandbox*/ None,
|
| 263 |
+
)
|
| 264 |
+
.await
|
| 265 |
+
.unwrap();
|
| 266 |
+
let contents = fs::read_to_string(path).unwrap();
|
| 267 |
+
assert_eq!(
|
| 268 |
+
contents,
|
| 269 |
+
r#"a
|
| 270 |
+
B
|
| 271 |
+
c
|
| 272 |
+
d
|
| 273 |
+
E
|
| 274 |
+
f
|
| 275 |
+
g
|
| 276 |
+
"#
|
| 277 |
+
);
|
| 278 |
+
}
|
codex-rs/apply-patch/src/invocation.rs
ADDED
|
@@ -0,0 +1,1036 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::collections::HashMap;
|
| 2 |
+
use std::sync::LazyLock;
|
| 3 |
+
|
| 4 |
+
use codex_exec_server::ExecutorFileSystem;
|
| 5 |
+
use tree_sitter::Parser;
|
| 6 |
+
use tree_sitter::Query;
|
| 7 |
+
use tree_sitter::QueryCursor;
|
| 8 |
+
use tree_sitter::StreamingIterator;
|
| 9 |
+
use tree_sitter_bash::LANGUAGE as BASH;
|
| 10 |
+
|
| 11 |
+
use crate::ApplyPatchAction;
|
| 12 |
+
use crate::ApplyPatchArgs;
|
| 13 |
+
use crate::ApplyPatchError;
|
| 14 |
+
use crate::ApplyPatchFileChange;
|
| 15 |
+
use crate::ApplyPatchFileUpdate;
|
| 16 |
+
use crate::ApplyPatchFileUpdateMode;
|
| 17 |
+
use crate::IoError;
|
| 18 |
+
use crate::MaybeApplyPatchVerified;
|
| 19 |
+
use crate::parser::Hunk;
|
| 20 |
+
use crate::parser::ParseError;
|
| 21 |
+
use crate::parser::parse_patch;
|
| 22 |
+
use crate::unified_diff_from_chunks_with_mode;
|
| 23 |
+
use codex_utils_path_uri::PathConvention;
|
| 24 |
+
use codex_utils_path_uri::PathUri;
|
| 25 |
+
use std::str::Utf8Error;
|
| 26 |
+
use tree_sitter::LanguageError;
|
| 27 |
+
|
| 28 |
+
const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
|
| 29 |
+
|
| 30 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 31 |
+
enum ApplyPatchShell {
|
| 32 |
+
Unix,
|
| 33 |
+
PowerShell,
|
| 34 |
+
Cmd,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
#[derive(Debug, PartialEq)]
|
| 38 |
+
pub enum MaybeApplyPatch {
|
| 39 |
+
Body(ApplyPatchArgs),
|
| 40 |
+
ShellParseError(ExtractHeredocError),
|
| 41 |
+
PatchParseError(ParseError),
|
| 42 |
+
NotApplyPatch,
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
#[derive(Debug, PartialEq)]
|
| 46 |
+
pub enum ExtractHeredocError {
|
| 47 |
+
CommandDidNotStartWithApplyPatch,
|
| 48 |
+
FailedToLoadBashGrammar(LanguageError),
|
| 49 |
+
HeredocNotUtf8(Utf8Error),
|
| 50 |
+
FailedToParsePatchIntoAst,
|
| 51 |
+
FailedToFindHeredocBody,
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
fn classify_shell_name(shell: &str, convention: PathConvention) -> Option<String> {
|
| 55 |
+
let basename = convention.path_segments(shell).next_back()?;
|
| 56 |
+
let stem = basename
|
| 57 |
+
.rsplit_once('.')
|
| 58 |
+
.and_then(|(stem, _extension)| (!stem.is_empty()).then_some(stem))
|
| 59 |
+
.unwrap_or(basename);
|
| 60 |
+
Some(stem.to_ascii_lowercase())
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
fn classify_shell(shell: &str, flag: &str, convention: PathConvention) -> Option<ApplyPatchShell> {
|
| 64 |
+
classify_shell_name(shell, convention).and_then(|name| match name.as_str() {
|
| 65 |
+
"bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix),
|
| 66 |
+
"pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => {
|
| 67 |
+
Some(ApplyPatchShell::PowerShell)
|
| 68 |
+
}
|
| 69 |
+
"cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd),
|
| 70 |
+
_ => None,
|
| 71 |
+
})
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
fn can_skip_flag(shell: &str, flag: &str, convention: PathConvention) -> bool {
|
| 75 |
+
classify_shell_name(shell, convention).is_some_and(|name| {
|
| 76 |
+
matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile")
|
| 77 |
+
})
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
fn parse_shell_script<'a>(argv: &'a [String], cwd: &PathUri) -> Option<(ApplyPatchShell, &'a str)> {
|
| 81 |
+
let convention = cwd.infer_path_convention()?;
|
| 82 |
+
match argv {
|
| 83 |
+
[shell, flag, script] => classify_shell(shell, flag, convention).map(|shell_type| {
|
| 84 |
+
let script = script.as_str();
|
| 85 |
+
(shell_type, script)
|
| 86 |
+
}),
|
| 87 |
+
[shell, skip_flag, flag, script] => {
|
| 88 |
+
if !can_skip_flag(shell, skip_flag, convention) {
|
| 89 |
+
return None;
|
| 90 |
+
}
|
| 91 |
+
classify_shell(shell, flag, convention).map(|shell_type| {
|
| 92 |
+
let script = script.as_str();
|
| 93 |
+
(shell_type, script)
|
| 94 |
+
})
|
| 95 |
+
}
|
| 96 |
+
_ => None,
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
fn extract_apply_patch_from_shell(
|
| 101 |
+
shell: ApplyPatchShell,
|
| 102 |
+
script: &str,
|
| 103 |
+
) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
|
| 104 |
+
match shell {
|
| 105 |
+
ApplyPatchShell::Unix | ApplyPatchShell::PowerShell | ApplyPatchShell::Cmd => {
|
| 106 |
+
extract_apply_patch_from_bash(script)
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// TODO: make private once we remove tests in lib.rs
|
| 112 |
+
/// `cwd` supplies the path convention used to interpret the shell executable in `argv`.
|
| 113 |
+
pub fn maybe_parse_apply_patch(argv: &[String], cwd: &PathUri) -> MaybeApplyPatch {
|
| 114 |
+
match argv {
|
| 115 |
+
// Direct invocation: apply_patch <patch>
|
| 116 |
+
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
|
| 117 |
+
Ok(source) => MaybeApplyPatch::Body(source),
|
| 118 |
+
Err(e) => MaybeApplyPatch::PatchParseError(e),
|
| 119 |
+
},
|
| 120 |
+
// Shell heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
|
| 121 |
+
_ => match parse_shell_script(argv, cwd) {
|
| 122 |
+
Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) {
|
| 123 |
+
Ok((body, workdir)) => match parse_patch(&body) {
|
| 124 |
+
Ok(mut source) => {
|
| 125 |
+
source.workdir = workdir;
|
| 126 |
+
MaybeApplyPatch::Body(source)
|
| 127 |
+
}
|
| 128 |
+
Err(e) => MaybeApplyPatch::PatchParseError(e),
|
| 129 |
+
},
|
| 130 |
+
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => {
|
| 131 |
+
MaybeApplyPatch::NotApplyPatch
|
| 132 |
+
}
|
| 133 |
+
Err(e) => MaybeApplyPatch::ShellParseError(e),
|
| 134 |
+
},
|
| 135 |
+
None => MaybeApplyPatch::NotApplyPatch,
|
| 136 |
+
},
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/// `cwd` must identify an absolute environment-native path so relative patch paths can be
|
| 141 |
+
/// resolved without projecting them onto the app-server or exec-server host.
|
| 142 |
+
pub async fn maybe_parse_apply_patch_verified(
|
| 143 |
+
argv: &[String],
|
| 144 |
+
cwd: &PathUri,
|
| 145 |
+
fs: &dyn ExecutorFileSystem,
|
| 146 |
+
sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
|
| 147 |
+
) -> MaybeApplyPatchVerified {
|
| 148 |
+
maybe_parse_apply_patch_verified_with_mode(
|
| 149 |
+
argv,
|
| 150 |
+
cwd,
|
| 151 |
+
ApplyPatchFileUpdateMode::default(),
|
| 152 |
+
fs,
|
| 153 |
+
sandbox,
|
| 154 |
+
)
|
| 155 |
+
.await
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/// Parses and verifies an `apply_patch` invocation using the selected
|
| 159 |
+
/// file-update mode.
|
| 160 |
+
pub async fn maybe_parse_apply_patch_verified_with_mode(
|
| 161 |
+
argv: &[String],
|
| 162 |
+
cwd: &PathUri,
|
| 163 |
+
update_file_mode: ApplyPatchFileUpdateMode,
|
| 164 |
+
fs: &dyn ExecutorFileSystem,
|
| 165 |
+
sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
|
| 166 |
+
) -> MaybeApplyPatchVerified {
|
| 167 |
+
// Detect a raw patch body passed directly as the command or as the body of a shell
|
| 168 |
+
// script. In these cases, report an explicit error rather than applying the patch.
|
| 169 |
+
if let [body] = argv
|
| 170 |
+
&& parse_patch(body).is_ok()
|
| 171 |
+
{
|
| 172 |
+
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
|
| 173 |
+
}
|
| 174 |
+
if let Some((_, script)) = parse_shell_script(argv, cwd)
|
| 175 |
+
&& parse_patch(script).is_ok()
|
| 176 |
+
{
|
| 177 |
+
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
match maybe_parse_apply_patch(argv, cwd) {
|
| 181 |
+
MaybeApplyPatch::Body(args) => {
|
| 182 |
+
verify_apply_patch_args_with_mode(args, cwd, update_file_mode, fs, sandbox).await
|
| 183 |
+
}
|
| 184 |
+
MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
|
| 185 |
+
MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),
|
| 186 |
+
MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch,
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
pub async fn verify_apply_patch_args(
|
| 191 |
+
args: ApplyPatchArgs,
|
| 192 |
+
cwd: &PathUri,
|
| 193 |
+
fs: &dyn ExecutorFileSystem,
|
| 194 |
+
sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
|
| 195 |
+
) -> MaybeApplyPatchVerified {
|
| 196 |
+
verify_apply_patch_args_with_mode(args, cwd, ApplyPatchFileUpdateMode::default(), fs, sandbox)
|
| 197 |
+
.await
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/// Verifies parsed patch arguments using the selected file-update mode.
|
| 201 |
+
pub async fn verify_apply_patch_args_with_mode(
|
| 202 |
+
args: ApplyPatchArgs,
|
| 203 |
+
cwd: &PathUri,
|
| 204 |
+
update_file_mode: ApplyPatchFileUpdateMode,
|
| 205 |
+
fs: &dyn ExecutorFileSystem,
|
| 206 |
+
sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
|
| 207 |
+
) -> MaybeApplyPatchVerified {
|
| 208 |
+
match try_verify_apply_patch_args(args, cwd, update_file_mode, fs, sandbox).await {
|
| 209 |
+
Ok(action) => MaybeApplyPatchVerified::Body(action),
|
| 210 |
+
Err(err) => MaybeApplyPatchVerified::CorrectnessError(err),
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
async fn try_verify_apply_patch_args(
|
| 215 |
+
args: ApplyPatchArgs,
|
| 216 |
+
cwd: &PathUri,
|
| 217 |
+
update_file_mode: ApplyPatchFileUpdateMode,
|
| 218 |
+
fs: &dyn ExecutorFileSystem,
|
| 219 |
+
sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
|
| 220 |
+
) -> Result<ApplyPatchAction, ApplyPatchError> {
|
| 221 |
+
let ApplyPatchArgs {
|
| 222 |
+
patch,
|
| 223 |
+
hunks,
|
| 224 |
+
workdir,
|
| 225 |
+
..
|
| 226 |
+
} = args;
|
| 227 |
+
let effective_cwd = workdir
|
| 228 |
+
.as_ref()
|
| 229 |
+
.map(|dir| cwd.join(dir))
|
| 230 |
+
.transpose()?
|
| 231 |
+
.unwrap_or_else(|| cwd.clone());
|
| 232 |
+
let mut changes = HashMap::new();
|
| 233 |
+
for hunk in hunks {
|
| 234 |
+
let path = hunk.resolve_path(&effective_cwd)?;
|
| 235 |
+
if changes.contains_key(&path) {
|
| 236 |
+
return Err(ParseError::InvalidPatchError(format!(
|
| 237 |
+
"multiple operations target {}",
|
| 238 |
+
path.inferred_native_path_string()
|
| 239 |
+
))
|
| 240 |
+
.into());
|
| 241 |
+
}
|
| 242 |
+
match hunk {
|
| 243 |
+
Hunk::AddFile { contents, .. } => {
|
| 244 |
+
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
|
| 245 |
+
}
|
| 246 |
+
Hunk::DeleteFile { .. } => {
|
| 247 |
+
let content = fs
|
| 248 |
+
.read_file_text(&path, Default::default(), sandbox)
|
| 249 |
+
.await
|
| 250 |
+
.map_err(|source| {
|
| 251 |
+
ApplyPatchError::IoError(IoError {
|
| 252 |
+
context: format!(
|
| 253 |
+
"Failed to read {}",
|
| 254 |
+
path.inferred_native_path_string()
|
| 255 |
+
),
|
| 256 |
+
source,
|
| 257 |
+
})
|
| 258 |
+
})?;
|
| 259 |
+
changes.insert(path, ApplyPatchFileChange::Delete { content });
|
| 260 |
+
}
|
| 261 |
+
Hunk::UpdateFile {
|
| 262 |
+
move_path, chunks, ..
|
| 263 |
+
} => {
|
| 264 |
+
let ApplyPatchFileUpdate {
|
| 265 |
+
unified_diff,
|
| 266 |
+
content: contents,
|
| 267 |
+
..
|
| 268 |
+
} = unified_diff_from_chunks_with_mode(
|
| 269 |
+
&path,
|
| 270 |
+
&chunks,
|
| 271 |
+
update_file_mode,
|
| 272 |
+
fs,
|
| 273 |
+
sandbox,
|
| 274 |
+
)
|
| 275 |
+
.await?;
|
| 276 |
+
changes.insert(
|
| 277 |
+
path,
|
| 278 |
+
ApplyPatchFileChange::Update {
|
| 279 |
+
unified_diff,
|
| 280 |
+
move_path: move_path
|
| 281 |
+
.map(|path| effective_cwd.join(&path.to_string_lossy()))
|
| 282 |
+
.transpose()?,
|
| 283 |
+
new_content: contents,
|
| 284 |
+
},
|
| 285 |
+
);
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
Ok(ApplyPatchAction {
|
| 290 |
+
changes,
|
| 291 |
+
update_file_mode,
|
| 292 |
+
patch,
|
| 293 |
+
cwd: effective_cwd,
|
| 294 |
+
})
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script
|
| 298 |
+
/// that invokes the apply_patch tool using a heredoc.
|
| 299 |
+
///
|
| 300 |
+
/// Supported top‑level forms (must be the only top‑level statement):
|
| 301 |
+
/// - `apply_patch <<'EOF'\n...\nEOF`
|
| 302 |
+
/// - `cd <path> && apply_patch <<'EOF'\n...\nEOF`
|
| 303 |
+
///
|
| 304 |
+
/// Notes about matching:
|
| 305 |
+
/// - Parsed with Tree‑sitter Bash and a strict query that uses anchors so the
|
| 306 |
+
/// heredoc‑redirected statement is the only top‑level statement.
|
| 307 |
+
/// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`).
|
| 308 |
+
/// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted
|
| 309 |
+
/// strings, no second argument).
|
| 310 |
+
/// - The apply command is validated in‑query via `#any-of?` to allow `apply_patch`
|
| 311 |
+
/// or `applypatch`.
|
| 312 |
+
/// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match.
|
| 313 |
+
///
|
| 314 |
+
/// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or
|
| 315 |
+
/// `(heredoc_body, None)` for the direct form. Errors are returned if the script
|
| 316 |
+
/// cannot be parsed or does not match the allowed patterns.
|
| 317 |
+
fn extract_apply_patch_from_bash(
|
| 318 |
+
src: &str,
|
| 319 |
+
) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
|
| 320 |
+
// This function uses a Tree-sitter query to recognize one of two
|
| 321 |
+
// whole-script forms, each expressed as a single top-level statement:
|
| 322 |
+
//
|
| 323 |
+
// 1. apply_patch <<'EOF'\n...\nEOF
|
| 324 |
+
// 2. cd <path> && apply_patch <<'EOF'\n...\nEOF
|
| 325 |
+
//
|
| 326 |
+
// Key ideas when reading the query:
|
| 327 |
+
// - dots (`.`) between named nodes enforces adjacency among named children and
|
| 328 |
+
// anchor to the start/end of the expression.
|
| 329 |
+
// - we match a single redirected_statement directly under program with leading
|
| 330 |
+
// and trailing anchors (`.`). This ensures it is the only top-level statement
|
| 331 |
+
// (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match).
|
| 332 |
+
//
|
| 333 |
+
// Overall, we want to be conservative and only match the intended forms, as other
|
| 334 |
+
// forms are likely to be model errors, or incorrectly interpreted by later code.
|
| 335 |
+
//
|
| 336 |
+
// If you're editing this query, it's helpful to start by creating a debugging binary
|
| 337 |
+
// which will let you see the AST of an arbitrary bash script passed in, and optionally
|
| 338 |
+
// also run an arbitrary query against the AST. This is useful for understanding
|
| 339 |
+
// how tree-sitter parses the script and whether the query syntax is correct. Be sure
|
| 340 |
+
// to test both positive and negative cases.
|
| 341 |
+
static APPLY_PATCH_QUERY: LazyLock<Query> = LazyLock::new(|| {
|
| 342 |
+
let language = BASH.into();
|
| 343 |
+
#[expect(clippy::expect_used)]
|
| 344 |
+
Query::new(
|
| 345 |
+
&language,
|
| 346 |
+
r#"
|
| 347 |
+
(
|
| 348 |
+
program
|
| 349 |
+
. (redirected_statement
|
| 350 |
+
body: (command
|
| 351 |
+
name: (command_name (word) @apply_name) .)
|
| 352 |
+
(#any-of? @apply_name "apply_patch" "applypatch")
|
| 353 |
+
redirect: (heredoc_redirect
|
| 354 |
+
. (heredoc_start)
|
| 355 |
+
. (heredoc_body) @heredoc
|
| 356 |
+
. (heredoc_end)
|
| 357 |
+
.))
|
| 358 |
+
.)
|
| 359 |
+
|
| 360 |
+
(
|
| 361 |
+
program
|
| 362 |
+
. (redirected_statement
|
| 363 |
+
body: (list
|
| 364 |
+
. (command
|
| 365 |
+
name: (command_name (word) @cd_name) .
|
| 366 |
+
argument: [
|
| 367 |
+
(word) @cd_path
|
| 368 |
+
(string (string_content) @cd_path)
|
| 369 |
+
(raw_string) @cd_raw_string
|
| 370 |
+
] .)
|
| 371 |
+
"&&"
|
| 372 |
+
. (command
|
| 373 |
+
name: (command_name (word) @apply_name))
|
| 374 |
+
.)
|
| 375 |
+
(#eq? @cd_name "cd")
|
| 376 |
+
(#any-of? @apply_name "apply_patch" "applypatch")
|
| 377 |
+
redirect: (heredoc_redirect
|
| 378 |
+
. (heredoc_start)
|
| 379 |
+
. (heredoc_body) @heredoc
|
| 380 |
+
. (heredoc_end)
|
| 381 |
+
.))
|
| 382 |
+
.)
|
| 383 |
+
"#,
|
| 384 |
+
)
|
| 385 |
+
.expect("valid bash query")
|
| 386 |
+
});
|
| 387 |
+
|
| 388 |
+
let lang = BASH.into();
|
| 389 |
+
let mut parser = Parser::new();
|
| 390 |
+
parser
|
| 391 |
+
.set_language(&lang)
|
| 392 |
+
.map_err(ExtractHeredocError::FailedToLoadBashGrammar)?;
|
| 393 |
+
let tree = parser
|
| 394 |
+
.parse(src, None)
|
| 395 |
+
.ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?;
|
| 396 |
+
|
| 397 |
+
let bytes = src.as_bytes();
|
| 398 |
+
let root = tree.root_node();
|
| 399 |
+
|
| 400 |
+
let mut cursor = QueryCursor::new();
|
| 401 |
+
let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes);
|
| 402 |
+
while let Some(m) = matches.next() {
|
| 403 |
+
let mut heredoc_text: Option<String> = None;
|
| 404 |
+
let mut cd_path: Option<String> = None;
|
| 405 |
+
|
| 406 |
+
for capture in m.captures.iter() {
|
| 407 |
+
let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize];
|
| 408 |
+
match name {
|
| 409 |
+
"heredoc" => {
|
| 410 |
+
let text = capture
|
| 411 |
+
.node
|
| 412 |
+
.utf8_text(bytes)
|
| 413 |
+
.map_err(ExtractHeredocError::HeredocNotUtf8)?
|
| 414 |
+
.trim_end_matches('\n')
|
| 415 |
+
.to_string();
|
| 416 |
+
heredoc_text = Some(text);
|
| 417 |
+
}
|
| 418 |
+
"cd_path" => {
|
| 419 |
+
let text = capture
|
| 420 |
+
.node
|
| 421 |
+
.utf8_text(bytes)
|
| 422 |
+
.map_err(ExtractHeredocError::HeredocNotUtf8)?
|
| 423 |
+
.to_string();
|
| 424 |
+
cd_path = Some(text);
|
| 425 |
+
}
|
| 426 |
+
"cd_raw_string" => {
|
| 427 |
+
let raw = capture
|
| 428 |
+
.node
|
| 429 |
+
.utf8_text(bytes)
|
| 430 |
+
.map_err(ExtractHeredocError::HeredocNotUtf8)?;
|
| 431 |
+
let trimmed = raw
|
| 432 |
+
.strip_prefix('\'')
|
| 433 |
+
.and_then(|s| s.strip_suffix('\''))
|
| 434 |
+
.unwrap_or(raw);
|
| 435 |
+
cd_path = Some(trimmed.to_string());
|
| 436 |
+
}
|
| 437 |
+
_ => {}
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
if let Some(heredoc) = heredoc_text {
|
| 442 |
+
return Ok((heredoc, cd_path));
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch)
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
#[cfg(test)]
|
| 450 |
+
mod tests {
|
| 451 |
+
use super::*;
|
| 452 |
+
use crate::unified_diff_from_chunks;
|
| 453 |
+
use assert_matches::assert_matches;
|
| 454 |
+
use codex_exec_server::LOCAL_FS;
|
| 455 |
+
use pretty_assertions::assert_eq;
|
| 456 |
+
use std::fs;
|
| 457 |
+
use std::path::PathBuf;
|
| 458 |
+
use std::string::ToString;
|
| 459 |
+
use tempfile::tempdir;
|
| 460 |
+
|
| 461 |
+
/// Helper to construct a patch with the given body.
|
| 462 |
+
fn wrap_patch(body: &str) -> String {
|
| 463 |
+
format!("*** Begin Patch\n{body}\n*** End Patch")
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
fn strs_to_strings(strs: &[&str]) -> Vec<String> {
|
| 467 |
+
strs.iter().map(ToString::to_string).collect()
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
// Test helpers to reduce repetition when building bash -lc heredoc scripts
|
| 471 |
+
fn args_bash(script: &str) -> Vec<String> {
|
| 472 |
+
strs_to_strings(&["bash", "-lc", script])
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
fn args_powershell(script: &str) -> Vec<String> {
|
| 476 |
+
strs_to_strings(&["powershell.exe", "-Command", script])
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
fn args_powershell_no_profile(script: &str) -> Vec<String> {
|
| 480 |
+
strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script])
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
fn args_pwsh(script: &str) -> Vec<String> {
|
| 484 |
+
strs_to_strings(&["pwsh", "-NoProfile", "-Command", script])
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
fn args_cmd(script: &str) -> Vec<String> {
|
| 488 |
+
strs_to_strings(&["cmd.exe", "/c", script])
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
fn heredoc_script(prefix: &str) -> String {
|
| 492 |
+
format!(
|
| 493 |
+
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
|
| 494 |
+
)
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
fn heredoc_script_ps(prefix: &str, suffix: &str) -> String {
|
| 498 |
+
format!(
|
| 499 |
+
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}"
|
| 500 |
+
)
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
fn expected_single_add() -> Vec<Hunk> {
|
| 504 |
+
vec![Hunk::AddFile {
|
| 505 |
+
path: PathBuf::from("foo"),
|
| 506 |
+
contents: "hi\n".to_string(),
|
| 507 |
+
}]
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
#[track_caller]
|
| 511 |
+
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
|
| 512 |
+
assert_match_args_with_cwd(
|
| 513 |
+
args,
|
| 514 |
+
&PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
|
| 515 |
+
expected_workdir,
|
| 516 |
+
);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
#[track_caller]
|
| 520 |
+
fn assert_match_args_with_cwd(
|
| 521 |
+
args: Vec<String>,
|
| 522 |
+
cwd: &PathUri,
|
| 523 |
+
expected_workdir: Option<&str>,
|
| 524 |
+
) {
|
| 525 |
+
match maybe_parse_apply_patch(&args, cwd) {
|
| 526 |
+
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
| 527 |
+
assert_eq!(workdir.as_deref(), expected_workdir);
|
| 528 |
+
assert_eq!(hunks, expected_single_add());
|
| 529 |
+
}
|
| 530 |
+
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
| 531 |
+
}
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
#[track_caller]
|
| 535 |
+
fn assert_match(script: &str, expected_workdir: Option<&str>) {
|
| 536 |
+
let args = args_bash(script);
|
| 537 |
+
assert_match_args(args, expected_workdir);
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
fn assert_not_match(script: &str) {
|
| 541 |
+
let args = args_bash(script);
|
| 542 |
+
assert_matches!(
|
| 543 |
+
maybe_parse_apply_patch(
|
| 544 |
+
&args,
|
| 545 |
+
&PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
|
| 546 |
+
),
|
| 547 |
+
MaybeApplyPatch::NotApplyPatch
|
| 548 |
+
);
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
#[tokio::test]
|
| 552 |
+
async fn test_implicit_patch_single_arg_is_error() {
|
| 553 |
+
let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
|
| 554 |
+
let args = vec![patch];
|
| 555 |
+
let dir = tempdir().unwrap();
|
| 556 |
+
assert_matches!(
|
| 557 |
+
maybe_parse_apply_patch_verified(
|
| 558 |
+
&args,
|
| 559 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 560 |
+
LOCAL_FS.as_ref(),
|
| 561 |
+
/*sandbox*/ None,
|
| 562 |
+
)
|
| 563 |
+
.await,
|
| 564 |
+
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
| 565 |
+
);
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
#[tokio::test]
|
| 569 |
+
async fn test_implicit_patch_bash_script_is_error() {
|
| 570 |
+
let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
|
| 571 |
+
let args = args_bash(script);
|
| 572 |
+
let dir = tempdir().unwrap();
|
| 573 |
+
assert_matches!(
|
| 574 |
+
maybe_parse_apply_patch_verified(
|
| 575 |
+
&args,
|
| 576 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 577 |
+
LOCAL_FS.as_ref(),
|
| 578 |
+
/*sandbox*/ None,
|
| 579 |
+
)
|
| 580 |
+
.await,
|
| 581 |
+
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
| 582 |
+
);
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
#[tokio::test]
|
| 586 |
+
async fn test_literal() {
|
| 587 |
+
let args = strs_to_strings(&[
|
| 588 |
+
"apply_patch",
|
| 589 |
+
r#"*** Begin Patch
|
| 590 |
+
*** Add File: foo
|
| 591 |
+
+hi
|
| 592 |
+
*** End Patch
|
| 593 |
+
"#,
|
| 594 |
+
]);
|
| 595 |
+
|
| 596 |
+
match maybe_parse_apply_patch(
|
| 597 |
+
&args,
|
| 598 |
+
&PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
|
| 599 |
+
) {
|
| 600 |
+
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
|
| 601 |
+
assert_eq!(
|
| 602 |
+
hunks,
|
| 603 |
+
vec![Hunk::AddFile {
|
| 604 |
+
path: PathBuf::from("foo"),
|
| 605 |
+
contents: "hi\n".to_string()
|
| 606 |
+
}]
|
| 607 |
+
);
|
| 608 |
+
}
|
| 609 |
+
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
| 610 |
+
}
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
#[tokio::test]
|
| 614 |
+
async fn test_literal_applypatch() {
|
| 615 |
+
let args = strs_to_strings(&[
|
| 616 |
+
"applypatch",
|
| 617 |
+
r#"*** Begin Patch
|
| 618 |
+
*** Add File: foo
|
| 619 |
+
+hi
|
| 620 |
+
*** End Patch
|
| 621 |
+
"#,
|
| 622 |
+
]);
|
| 623 |
+
|
| 624 |
+
match maybe_parse_apply_patch(
|
| 625 |
+
&args,
|
| 626 |
+
&PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
|
| 627 |
+
) {
|
| 628 |
+
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
|
| 629 |
+
assert_eq!(
|
| 630 |
+
hunks,
|
| 631 |
+
vec![Hunk::AddFile {
|
| 632 |
+
path: PathBuf::from("foo"),
|
| 633 |
+
contents: "hi\n".to_string()
|
| 634 |
+
}]
|
| 635 |
+
);
|
| 636 |
+
}
|
| 637 |
+
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
| 638 |
+
}
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
#[tokio::test]
|
| 642 |
+
async fn test_heredoc() {
|
| 643 |
+
assert_match(&heredoc_script(""), /*expected_workdir*/ None);
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
#[tokio::test]
|
| 647 |
+
async fn test_heredoc_non_login_shell() {
|
| 648 |
+
let script = heredoc_script("");
|
| 649 |
+
let args = strs_to_strings(&["bash", "-c", &script]);
|
| 650 |
+
assert_match_args(args, /*expected_workdir*/ None);
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
#[tokio::test]
|
| 654 |
+
async fn test_heredoc_applypatch() {
|
| 655 |
+
let args = strs_to_strings(&[
|
| 656 |
+
"bash",
|
| 657 |
+
"-lc",
|
| 658 |
+
r#"applypatch <<'PATCH'
|
| 659 |
+
*** Begin Patch
|
| 660 |
+
*** Add File: foo
|
| 661 |
+
+hi
|
| 662 |
+
*** End Patch
|
| 663 |
+
PATCH"#,
|
| 664 |
+
]);
|
| 665 |
+
|
| 666 |
+
match maybe_parse_apply_patch(
|
| 667 |
+
&args,
|
| 668 |
+
&PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
|
| 669 |
+
) {
|
| 670 |
+
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
| 671 |
+
assert_eq!(workdir, None);
|
| 672 |
+
assert_eq!(
|
| 673 |
+
hunks,
|
| 674 |
+
vec![Hunk::AddFile {
|
| 675 |
+
path: PathBuf::from("foo"),
|
| 676 |
+
contents: "hi\n".to_string()
|
| 677 |
+
}]
|
| 678 |
+
);
|
| 679 |
+
}
|
| 680 |
+
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
| 681 |
+
}
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
#[tokio::test]
|
| 685 |
+
async fn test_powershell_heredoc() {
|
| 686 |
+
let script = heredoc_script("");
|
| 687 |
+
assert_match_args(args_powershell(&script), /*expected_workdir*/ None);
|
| 688 |
+
}
|
| 689 |
+
#[tokio::test]
|
| 690 |
+
async fn test_powershell_heredoc_no_profile() {
|
| 691 |
+
let script = heredoc_script("");
|
| 692 |
+
assert_match_args(
|
| 693 |
+
args_powershell_no_profile(&script),
|
| 694 |
+
/*expected_workdir*/ None,
|
| 695 |
+
);
|
| 696 |
+
}
|
| 697 |
+
#[tokio::test]
|
| 698 |
+
async fn test_pwsh_heredoc() {
|
| 699 |
+
let script = heredoc_script("");
|
| 700 |
+
assert_match_args(args_pwsh(&script), /*expected_workdir*/ None);
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
#[tokio::test]
|
| 704 |
+
async fn test_apply_patch_interception_uses_cwd_convention_for_windows_pwsh_path() {
|
| 705 |
+
let script = heredoc_script("");
|
| 706 |
+
assert_match_args_with_cwd(
|
| 707 |
+
strs_to_strings(&[
|
| 708 |
+
r"C:\Program Files\PowerShell\7\pwsh.exe",
|
| 709 |
+
"-NoProfile",
|
| 710 |
+
"-Command",
|
| 711 |
+
&script,
|
| 712 |
+
]),
|
| 713 |
+
&PathUri::parse("file:///C:/windows").expect("valid Windows test cwd"),
|
| 714 |
+
/*expected_workdir*/ None,
|
| 715 |
+
);
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
#[tokio::test]
|
| 719 |
+
async fn test_cmd_heredoc_with_cd() {
|
| 720 |
+
let script = heredoc_script("cd foo && ");
|
| 721 |
+
assert_match_args(args_cmd(&script), Some("foo"));
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
#[tokio::test]
|
| 725 |
+
async fn test_heredoc_with_leading_cd() {
|
| 726 |
+
assert_match(&heredoc_script("cd foo && "), Some("foo"));
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
#[tokio::test]
|
| 730 |
+
async fn test_cd_with_semicolon_is_ignored() {
|
| 731 |
+
assert_not_match(&heredoc_script("cd foo; "));
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
#[tokio::test]
|
| 735 |
+
async fn test_cd_or_apply_patch_is_ignored() {
|
| 736 |
+
assert_not_match(&heredoc_script("cd bar || "));
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
#[tokio::test]
|
| 740 |
+
async fn test_cd_pipe_apply_patch_is_ignored() {
|
| 741 |
+
assert_not_match(&heredoc_script("cd bar | "));
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
#[tokio::test]
|
| 745 |
+
async fn test_cd_single_quoted_path_with_spaces() {
|
| 746 |
+
assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
#[tokio::test]
|
| 750 |
+
async fn test_cd_double_quoted_path_with_spaces() {
|
| 751 |
+
assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
#[tokio::test]
|
| 755 |
+
async fn test_echo_and_apply_patch_is_ignored() {
|
| 756 |
+
assert_not_match(&heredoc_script("echo foo && "));
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
#[tokio::test]
|
| 760 |
+
async fn test_apply_patch_with_arg_is_ignored() {
|
| 761 |
+
let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
|
| 762 |
+
assert_not_match(script);
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
#[tokio::test]
|
| 766 |
+
async fn test_double_cd_then_apply_patch_is_ignored() {
|
| 767 |
+
assert_not_match(&heredoc_script("cd foo && cd bar && "));
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
#[tokio::test]
|
| 771 |
+
async fn test_cd_two_args_is_ignored() {
|
| 772 |
+
assert_not_match(&heredoc_script("cd foo bar && "));
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
#[tokio::test]
|
| 776 |
+
async fn test_cd_then_apply_patch_then_extra_is_ignored() {
|
| 777 |
+
let script = heredoc_script_ps("cd bar && ", " && echo done");
|
| 778 |
+
assert_not_match(&script);
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
#[tokio::test]
|
| 782 |
+
async fn test_echo_then_cd_and_apply_patch_is_ignored() {
|
| 783 |
+
// Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match.
|
| 784 |
+
assert_not_match(&heredoc_script("echo foo; cd bar && "));
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
#[tokio::test]
|
| 788 |
+
async fn test_unified_diff_last_line_replacement() {
|
| 789 |
+
// Replace the very last line of the file.
|
| 790 |
+
let dir = tempdir().unwrap();
|
| 791 |
+
let path = dir.path().join("last.txt");
|
| 792 |
+
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
|
| 793 |
+
|
| 794 |
+
let patch = wrap_patch(&format!(
|
| 795 |
+
r#"*** Update File: {}
|
| 796 |
+
@@
|
| 797 |
+
foo
|
| 798 |
+
bar
|
| 799 |
+
-baz
|
| 800 |
+
+BAZ
|
| 801 |
+
"#,
|
| 802 |
+
path.display()
|
| 803 |
+
));
|
| 804 |
+
|
| 805 |
+
let patch = parse_patch(&patch).unwrap();
|
| 806 |
+
let chunks = match patch.hunks.as_slice() {
|
| 807 |
+
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
| 808 |
+
_ => panic!("Expected a single UpdateFile hunk"),
|
| 809 |
+
};
|
| 810 |
+
|
| 811 |
+
let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
|
| 812 |
+
let diff =
|
| 813 |
+
unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
| 814 |
+
.await
|
| 815 |
+
.unwrap();
|
| 816 |
+
let expected_diff = r#"@@ -2,2 +2,2 @@
|
| 817 |
+
bar
|
| 818 |
+
-baz
|
| 819 |
+
+BAZ
|
| 820 |
+
"#;
|
| 821 |
+
let expected = ApplyPatchFileUpdate {
|
| 822 |
+
unified_diff: expected_diff.to_string(),
|
| 823 |
+
original_content: "foo\nbar\nbaz\n".to_string(),
|
| 824 |
+
content: "foo\nbar\nBAZ\n".to_string(),
|
| 825 |
+
};
|
| 826 |
+
assert_eq!(expected, diff);
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
#[tokio::test]
|
| 830 |
+
async fn test_unified_diff_insert_at_eof() {
|
| 831 |
+
// Insert a new line at end‑of‑file.
|
| 832 |
+
let dir = tempdir().unwrap();
|
| 833 |
+
let path = dir.path().join("insert.txt");
|
| 834 |
+
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
|
| 835 |
+
|
| 836 |
+
let patch = wrap_patch(&format!(
|
| 837 |
+
r#"*** Update File: {}
|
| 838 |
+
@@
|
| 839 |
+
+quux
|
| 840 |
+
*** End of File
|
| 841 |
+
"#,
|
| 842 |
+
path.display()
|
| 843 |
+
));
|
| 844 |
+
|
| 845 |
+
let patch = parse_patch(&patch).unwrap();
|
| 846 |
+
let chunks = match patch.hunks.as_slice() {
|
| 847 |
+
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
| 848 |
+
_ => panic!("Expected a single UpdateFile hunk"),
|
| 849 |
+
};
|
| 850 |
+
|
| 851 |
+
let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
|
| 852 |
+
let diff =
|
| 853 |
+
unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
| 854 |
+
.await
|
| 855 |
+
.unwrap();
|
| 856 |
+
let expected_diff = r#"@@ -3 +3,2 @@
|
| 857 |
+
baz
|
| 858 |
+
+quux
|
| 859 |
+
"#;
|
| 860 |
+
let expected = ApplyPatchFileUpdate {
|
| 861 |
+
unified_diff: expected_diff.to_string(),
|
| 862 |
+
original_content: "foo\nbar\nbaz\n".to_string(),
|
| 863 |
+
content: "foo\nbar\nbaz\nquux\n".to_string(),
|
| 864 |
+
};
|
| 865 |
+
assert_eq!(expected, diff);
|
| 866 |
+
}
|
| 867 |
+
|
| 868 |
+
#[tokio::test]
|
| 869 |
+
async fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
|
| 870 |
+
let session_dir = tempdir().unwrap();
|
| 871 |
+
let relative_path = "source.txt";
|
| 872 |
+
|
| 873 |
+
// Note that we need this file to exist for the patch to be "verified"
|
| 874 |
+
// and parsed correctly.
|
| 875 |
+
let session_file_path = session_dir.path().join(relative_path);
|
| 876 |
+
fs::write(&session_file_path, "session directory content\n").unwrap();
|
| 877 |
+
|
| 878 |
+
let argv = vec![
|
| 879 |
+
"apply_patch".to_string(),
|
| 880 |
+
r#"*** Begin Patch
|
| 881 |
+
*** Update File: source.txt
|
| 882 |
+
@@
|
| 883 |
+
-session directory content
|
| 884 |
+
+updated session directory content
|
| 885 |
+
*** End Patch"#
|
| 886 |
+
.to_string(),
|
| 887 |
+
];
|
| 888 |
+
|
| 889 |
+
let result = maybe_parse_apply_patch_verified(
|
| 890 |
+
&argv,
|
| 891 |
+
&PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"),
|
| 892 |
+
LOCAL_FS.as_ref(),
|
| 893 |
+
/*sandbox*/ None,
|
| 894 |
+
)
|
| 895 |
+
.await;
|
| 896 |
+
|
| 897 |
+
// Verify the patch contents - as otherwise we may have pulled contents
|
| 898 |
+
// from the wrong file (as we're using relative paths)
|
| 899 |
+
assert_eq!(
|
| 900 |
+
result,
|
| 901 |
+
MaybeApplyPatchVerified::Body(ApplyPatchAction {
|
| 902 |
+
changes: HashMap::from([(
|
| 903 |
+
PathUri::from_host_native_path(session_dir.path().join(relative_path))
|
| 904 |
+
.expect("absolute test path"),
|
| 905 |
+
ApplyPatchFileChange::Update {
|
| 906 |
+
unified_diff: r#"@@ -1 +1 @@
|
| 907 |
+
-session directory content
|
| 908 |
+
+updated session directory content
|
| 909 |
+
"#
|
| 910 |
+
.to_string(),
|
| 911 |
+
move_path: None,
|
| 912 |
+
new_content: "updated session directory content\n".to_string(),
|
| 913 |
+
},
|
| 914 |
+
)]),
|
| 915 |
+
update_file_mode: ApplyPatchFileUpdateMode::default(),
|
| 916 |
+
patch: argv[1].clone(),
|
| 917 |
+
cwd: PathUri::from_host_native_path(session_dir.path())
|
| 918 |
+
.expect("absolute test path"),
|
| 919 |
+
})
|
| 920 |
+
);
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
#[tokio::test]
|
| 924 |
+
async fn test_apply_patch_resolves_move_path_with_effective_cwd() {
|
| 925 |
+
let session_dir = tempdir().unwrap();
|
| 926 |
+
let worktree_rel = "alt";
|
| 927 |
+
let worktree_dir = session_dir.path().join(worktree_rel);
|
| 928 |
+
fs::create_dir_all(&worktree_dir).unwrap();
|
| 929 |
+
|
| 930 |
+
let source_name = "old.txt";
|
| 931 |
+
let dest_name = "renamed.txt";
|
| 932 |
+
let source_path = worktree_dir.join(source_name);
|
| 933 |
+
fs::write(&source_path, "before\n").unwrap();
|
| 934 |
+
|
| 935 |
+
let patch = wrap_patch(&format!(
|
| 936 |
+
r#"*** Update File: {source_name}
|
| 937 |
+
*** Move to: {dest_name}
|
| 938 |
+
@@
|
| 939 |
+
-before
|
| 940 |
+
+after"#
|
| 941 |
+
));
|
| 942 |
+
|
| 943 |
+
let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH");
|
| 944 |
+
let argv = vec!["bash".into(), "-lc".into(), shell_script];
|
| 945 |
+
|
| 946 |
+
let result = maybe_parse_apply_patch_verified(
|
| 947 |
+
&argv,
|
| 948 |
+
&PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"),
|
| 949 |
+
LOCAL_FS.as_ref(),
|
| 950 |
+
/*sandbox*/ None,
|
| 951 |
+
)
|
| 952 |
+
.await;
|
| 953 |
+
let action = match result {
|
| 954 |
+
MaybeApplyPatchVerified::Body(action) => action,
|
| 955 |
+
other => panic!("expected verified body, got {other:?}"),
|
| 956 |
+
};
|
| 957 |
+
|
| 958 |
+
assert_eq!(
|
| 959 |
+
action.cwd.to_abs_path().unwrap().as_path(),
|
| 960 |
+
worktree_dir.as_path()
|
| 961 |
+
);
|
| 962 |
+
|
| 963 |
+
let source_path = PathUri::from_host_native_path(worktree_dir.join(source_name))
|
| 964 |
+
.expect("absolute test path");
|
| 965 |
+
let change = action
|
| 966 |
+
.changes()
|
| 967 |
+
.get(&source_path)
|
| 968 |
+
.expect("source file change present");
|
| 969 |
+
|
| 970 |
+
match change {
|
| 971 |
+
ApplyPatchFileChange::Update { move_path, .. } => {
|
| 972 |
+
let expected_move_path =
|
| 973 |
+
PathUri::from_host_native_path(worktree_dir.join(dest_name))
|
| 974 |
+
.expect("absolute test path");
|
| 975 |
+
assert_eq!(move_path.as_ref(), Some(&expected_move_path));
|
| 976 |
+
}
|
| 977 |
+
other => panic!("expected update change, got {other:?}"),
|
| 978 |
+
}
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
#[tokio::test]
|
| 982 |
+
async fn test_unreadable_destinations_still_verify() {
|
| 983 |
+
let session_dir = tempdir().unwrap();
|
| 984 |
+
fs::write(session_dir.path().join("binary.dat"), [0xff, 0xfe, 0xfd]).unwrap();
|
| 985 |
+
let cwd = PathUri::from_host_native_path(session_dir.path()).expect("absolute test path");
|
| 986 |
+
let add_argv = vec![
|
| 987 |
+
"apply_patch".to_string(),
|
| 988 |
+
"*** Begin Patch\n*** Add File: binary.dat\n+text\n*** End Patch".to_string(),
|
| 989 |
+
];
|
| 990 |
+
fs::write(session_dir.path().join("source.txt"), "before\n").unwrap();
|
| 991 |
+
let move_argv = vec![
|
| 992 |
+
"apply_patch".to_string(),
|
| 993 |
+
"*** Begin Patch\n*** Update File: source.txt\n*** Move to: binary.dat\n@@\n-before\n+after\n*** End Patch".to_string(),
|
| 994 |
+
];
|
| 995 |
+
|
| 996 |
+
for argv in [add_argv, move_argv] {
|
| 997 |
+
let result = maybe_parse_apply_patch_verified(
|
| 998 |
+
&argv,
|
| 999 |
+
&cwd,
|
| 1000 |
+
LOCAL_FS.as_ref(),
|
| 1001 |
+
/*sandbox*/ None,
|
| 1002 |
+
)
|
| 1003 |
+
.await;
|
| 1004 |
+
|
| 1005 |
+
assert!(matches!(result, MaybeApplyPatchVerified::Body(_)));
|
| 1006 |
+
}
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
#[cfg(unix)]
|
| 1010 |
+
#[tokio::test]
|
| 1011 |
+
async fn test_delete_symlink_still_verifies() {
|
| 1012 |
+
use std::os::unix::fs::symlink;
|
| 1013 |
+
|
| 1014 |
+
let session_dir = tempdir().unwrap();
|
| 1015 |
+
fs::write(session_dir.path().join("target.txt"), "target\n").unwrap();
|
| 1016 |
+
symlink(
|
| 1017 |
+
session_dir.path().join("target.txt"),
|
| 1018 |
+
session_dir.path().join("link.txt"),
|
| 1019 |
+
)
|
| 1020 |
+
.unwrap();
|
| 1021 |
+
let argv = vec![
|
| 1022 |
+
"apply_patch".to_string(),
|
| 1023 |
+
"*** Begin Patch\n*** Delete File: link.txt\n*** End Patch".to_string(),
|
| 1024 |
+
];
|
| 1025 |
+
|
| 1026 |
+
let result = maybe_parse_apply_patch_verified(
|
| 1027 |
+
&argv,
|
| 1028 |
+
&PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"),
|
| 1029 |
+
LOCAL_FS.as_ref(),
|
| 1030 |
+
/*sandbox*/ None,
|
| 1031 |
+
)
|
| 1032 |
+
.await;
|
| 1033 |
+
|
| 1034 |
+
assert!(matches!(result, MaybeApplyPatchVerified::Body(_)));
|
| 1035 |
+
}
|
| 1036 |
+
}
|
codex-rs/apply-patch/src/lib.rs
ADDED
|
@@ -0,0 +1,1444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mod file_update;
|
| 2 |
+
mod invocation;
|
| 3 |
+
mod parser;
|
| 4 |
+
mod seek_sequence;
|
| 5 |
+
mod standalone_executable;
|
| 6 |
+
mod streaming_parser;
|
| 7 |
+
mod text_file;
|
| 8 |
+
|
| 9 |
+
use std::collections::HashMap;
|
| 10 |
+
use std::io;
|
| 11 |
+
use std::path::PathBuf;
|
| 12 |
+
|
| 13 |
+
use anyhow::Context;
|
| 14 |
+
use anyhow::Result;
|
| 15 |
+
use codex_exec_server::CreateDirectoryOptions;
|
| 16 |
+
use codex_exec_server::ExecutorFileSystem;
|
| 17 |
+
use codex_exec_server::FileSystemSandboxContext;
|
| 18 |
+
use codex_exec_server::GetMetadataOptions;
|
| 19 |
+
use codex_exec_server::ReadFileOptions;
|
| 20 |
+
use codex_exec_server::RemoveOptions;
|
| 21 |
+
use codex_exec_server::WriteFileOptions;
|
| 22 |
+
use codex_utils_path_uri::PathUri;
|
| 23 |
+
use codex_utils_path_uri::PathUriParseError;
|
| 24 |
+
pub use parser::Hunk;
|
| 25 |
+
pub use parser::ParseError;
|
| 26 |
+
use parser::ParseError::*;
|
| 27 |
+
pub use parser::UpdateFileChunk;
|
| 28 |
+
pub use parser::parse_patch;
|
| 29 |
+
pub use streaming_parser::StreamingPatchParser;
|
| 30 |
+
use thiserror::Error;
|
| 31 |
+
|
| 32 |
+
use file_update::AppliedPatch;
|
| 33 |
+
pub use file_update::ApplyPatchFileUpdate;
|
| 34 |
+
use file_update::derive_new_contents_from_chunks;
|
| 35 |
+
pub use file_update::unified_diff_from_chunks;
|
| 36 |
+
pub use file_update::unified_diff_from_chunks_with_context;
|
| 37 |
+
pub(crate) use file_update::unified_diff_from_chunks_with_mode;
|
| 38 |
+
pub use invocation::MaybeApplyPatch;
|
| 39 |
+
pub use invocation::maybe_parse_apply_patch;
|
| 40 |
+
pub use invocation::maybe_parse_apply_patch_verified;
|
| 41 |
+
pub use invocation::maybe_parse_apply_patch_verified_with_mode;
|
| 42 |
+
pub use invocation::verify_apply_patch_args;
|
| 43 |
+
pub use invocation::verify_apply_patch_args_with_mode;
|
| 44 |
+
pub use standalone_executable::main;
|
| 45 |
+
|
| 46 |
+
use crate::invocation::ExtractHeredocError;
|
| 47 |
+
|
| 48 |
+
/// Special argv[1] flag used when the Codex executable self-invokes to run the
|
| 49 |
+
/// internal `apply_patch` path.
|
| 50 |
+
///
|
| 51 |
+
/// Although this constant lives in `codex-apply-patch` (to avoid forcing
|
| 52 |
+
/// `codex-arg0` to depend on `codex-core`), it remains part of the "codex core"
|
| 53 |
+
/// process-invocation contract for the standalone `apply_patch` command
|
| 54 |
+
/// surface.
|
| 55 |
+
pub const CODEX_CORE_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch";
|
| 56 |
+
|
| 57 |
+
/// Internal environment variable used to carry the selected update mode
|
| 58 |
+
/// through the arg0-dispatched standalone executable.
|
| 59 |
+
pub const CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR: &str =
|
| 60 |
+
"CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS";
|
| 61 |
+
|
| 62 |
+
/// Controls how updates reconstruct the target file after matching a patch.
|
| 63 |
+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
| 64 |
+
pub enum ApplyPatchFileUpdateMode {
|
| 65 |
+
/// Preserve the historical behavior of normalizing updated files to LF.
|
| 66 |
+
#[default]
|
| 67 |
+
NormalizeToLf,
|
| 68 |
+
/// Preserve existing line endings and use the file's preferred ending for new lines.
|
| 69 |
+
PreserveLineEndings,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/// Policy for one patch application. Standalone callers follow symlinks by default.
|
| 73 |
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
| 74 |
+
pub struct ApplyPatchOptions {
|
| 75 |
+
pub update_file_mode: ApplyPatchFileUpdateMode,
|
| 76 |
+
/// Whether filesystem operations may resolve symlinks in any path component.
|
| 77 |
+
pub follow_symlinks: bool,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
impl Default for ApplyPatchOptions {
|
| 81 |
+
fn default() -> Self {
|
| 82 |
+
Self {
|
| 83 |
+
update_file_mode: ApplyPatchFileUpdateMode::default(),
|
| 84 |
+
follow_symlinks: true,
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/// Reads the update mode selected for an arg0-dispatched `apply_patch` process.
|
| 90 |
+
#[doc(hidden)]
|
| 91 |
+
pub fn apply_patch_file_update_mode_from_env() -> ApplyPatchFileUpdateMode {
|
| 92 |
+
match std::env::var(CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR).as_deref() {
|
| 93 |
+
Ok("1") => ApplyPatchFileUpdateMode::PreserveLineEndings,
|
| 94 |
+
_ => ApplyPatchFileUpdateMode::NormalizeToLf,
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
#[derive(Debug, Error, PartialEq)]
|
| 99 |
+
pub enum ApplyPatchError {
|
| 100 |
+
#[error(transparent)]
|
| 101 |
+
ParseError(#[from] ParseError),
|
| 102 |
+
#[error(transparent)]
|
| 103 |
+
IoError(#[from] IoError),
|
| 104 |
+
/// Error that occurs while computing replacements when applying patch chunks
|
| 105 |
+
#[error("{0}")]
|
| 106 |
+
ComputeReplacements(String),
|
| 107 |
+
/// A patch path could not be resolved as a path URI.
|
| 108 |
+
#[error(transparent)]
|
| 109 |
+
PathUri(#[from] PathUriParseError),
|
| 110 |
+
/// A raw patch body was provided without an explicit `apply_patch` invocation.
|
| 111 |
+
#[error(
|
| 112 |
+
"patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"<patch>\"]"
|
| 113 |
+
)]
|
| 114 |
+
ImplicitInvocation,
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
impl From<std::io::Error> for ApplyPatchError {
|
| 118 |
+
fn from(err: std::io::Error) -> Self {
|
| 119 |
+
ApplyPatchError::IoError(IoError {
|
| 120 |
+
context: "I/O error".to_string(),
|
| 121 |
+
source: err,
|
| 122 |
+
})
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
impl From<&std::io::Error> for ApplyPatchError {
|
| 127 |
+
fn from(err: &std::io::Error) -> Self {
|
| 128 |
+
ApplyPatchError::IoError(IoError {
|
| 129 |
+
context: "I/O error".to_string(),
|
| 130 |
+
source: std::io::Error::new(err.kind(), err.to_string()),
|
| 131 |
+
})
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
#[derive(Debug, Error)]
|
| 136 |
+
#[error("{context}: {source}")]
|
| 137 |
+
pub struct IoError {
|
| 138 |
+
context: String,
|
| 139 |
+
#[source]
|
| 140 |
+
source: std::io::Error,
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
impl PartialEq for IoError {
|
| 144 |
+
fn eq(&self, other: &Self) -> bool {
|
| 145 |
+
self.context == other.context && self.source.to_string() == other.source.to_string()
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument
|
| 150 |
+
/// parsed into hunks.
|
| 151 |
+
#[derive(Debug, PartialEq)]
|
| 152 |
+
pub struct ApplyPatchArgs {
|
| 153 |
+
pub patch: String,
|
| 154 |
+
pub hunks: Vec<Hunk>,
|
| 155 |
+
pub workdir: Option<String>,
|
| 156 |
+
pub environment_id: Option<String>,
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
#[derive(Debug, PartialEq)]
|
| 160 |
+
pub enum ApplyPatchFileChange {
|
| 161 |
+
Add {
|
| 162 |
+
content: String,
|
| 163 |
+
},
|
| 164 |
+
Delete {
|
| 165 |
+
content: String,
|
| 166 |
+
},
|
| 167 |
+
Update {
|
| 168 |
+
unified_diff: String,
|
| 169 |
+
move_path: Option<PathUri>,
|
| 170 |
+
/// new_content that will result after the unified_diff is applied.
|
| 171 |
+
new_content: String,
|
| 172 |
+
},
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
#[derive(Debug, PartialEq)]
|
| 176 |
+
pub enum MaybeApplyPatchVerified {
|
| 177 |
+
/// `argv` corresponded to an `apply_patch` invocation, and these are the
|
| 178 |
+
/// resulting proposed file changes.
|
| 179 |
+
Body(ApplyPatchAction),
|
| 180 |
+
/// `argv` could not be parsed to determine whether it corresponds to an
|
| 181 |
+
/// `apply_patch` invocation.
|
| 182 |
+
ShellParseError(ExtractHeredocError),
|
| 183 |
+
/// `argv` corresponded to an `apply_patch` invocation, but it could not
|
| 184 |
+
/// be fulfilled due to the specified error.
|
| 185 |
+
CorrectnessError(ApplyPatchError),
|
| 186 |
+
/// `argv` decidedly did not correspond to an `apply_patch` invocation.
|
| 187 |
+
NotApplyPatch,
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
/// ApplyPatchAction is the result of parsing an `apply_patch` command. By
|
| 191 |
+
/// construction, all paths should be absolute paths.
|
| 192 |
+
#[derive(Debug, PartialEq)]
|
| 193 |
+
pub struct ApplyPatchAction {
|
| 194 |
+
changes: HashMap<PathUri, ApplyPatchFileChange>,
|
| 195 |
+
|
| 196 |
+
update_file_mode: ApplyPatchFileUpdateMode,
|
| 197 |
+
|
| 198 |
+
/// The raw patch argument that can be used to apply the patch. i.e., if the
|
| 199 |
+
/// original arg was parsed in "lenient" mode with a
|
| 200 |
+
/// heredoc, this should be the value without the heredoc wrapper.
|
| 201 |
+
pub patch: String,
|
| 202 |
+
|
| 203 |
+
/// The working directory that was used to resolve relative paths in the patch.
|
| 204 |
+
pub cwd: PathUri,
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
impl ApplyPatchAction {
|
| 208 |
+
pub fn is_empty(&self) -> bool {
|
| 209 |
+
self.changes.is_empty()
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/// Returns the changes that would be made by applying the patch.
|
| 213 |
+
pub fn changes(&self) -> &HashMap<PathUri, ApplyPatchFileChange> {
|
| 214 |
+
&self.changes
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
/// Returns the update mode selected while the patch was verified.
|
| 218 |
+
pub fn update_file_mode(&self) -> ApplyPatchFileUpdateMode {
|
| 219 |
+
self.update_file_mode
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/// Should be used exclusively for testing. (Not worth the overhead of
|
| 223 |
+
/// creating a feature flag for this.)
|
| 224 |
+
pub fn new_add_for_test(path: &PathUri, content: String) -> Self {
|
| 225 |
+
#[expect(clippy::expect_used)]
|
| 226 |
+
let filename = path.basename().expect("path should not be empty");
|
| 227 |
+
let patch = format!(
|
| 228 |
+
r#"*** Begin Patch
|
| 229 |
+
*** Update File: {filename}
|
| 230 |
+
@@
|
| 231 |
+
+ {content}
|
| 232 |
+
*** End Patch"#,
|
| 233 |
+
);
|
| 234 |
+
let changes = HashMap::from([(path.clone(), ApplyPatchFileChange::Add { content })]);
|
| 235 |
+
#[expect(clippy::expect_used)]
|
| 236 |
+
Self {
|
| 237 |
+
changes,
|
| 238 |
+
update_file_mode: ApplyPatchFileUpdateMode::default(),
|
| 239 |
+
cwd: path.parent().expect("path should have parent"),
|
| 240 |
+
patch,
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/// Textual file changes that were actually committed while applying a patch.
|
| 246 |
+
#[derive(Clone, Debug, PartialEq)]
|
| 247 |
+
pub struct AppliedPatchDelta {
|
| 248 |
+
changes: Vec<AppliedPatchChange>,
|
| 249 |
+
exact: bool,
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
impl AppliedPatchDelta {
|
| 253 |
+
fn new(changes: Vec<AppliedPatchChange>, exact: bool) -> Self {
|
| 254 |
+
Self { changes, exact }
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
fn empty() -> Self {
|
| 258 |
+
Self::new(Vec::new(), /*exact*/ true)
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
pub fn changes(&self) -> &[AppliedPatchChange] {
|
| 262 |
+
&self.changes
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
pub fn is_empty(&self) -> bool {
|
| 266 |
+
self.changes.is_empty()
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
pub fn is_exact(&self) -> bool {
|
| 270 |
+
self.exact
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
/// Appends a later committed prefix while preserving the aggregate exactness.
|
| 274 |
+
pub fn append(&mut self, other: Self) {
|
| 275 |
+
self.changes.extend(other.changes);
|
| 276 |
+
self.exact &= other.exact;
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
impl Default for AppliedPatchDelta {
|
| 281 |
+
fn default() -> Self {
|
| 282 |
+
Self::empty()
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
/// A committed file change, preserved in the order it was applied.
|
| 287 |
+
#[derive(Clone, Debug, PartialEq)]
|
| 288 |
+
pub struct AppliedPatchChange {
|
| 289 |
+
pub path: PathUri,
|
| 290 |
+
pub change: AppliedPatchFileChange,
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
#[derive(Clone, Debug, PartialEq)]
|
| 294 |
+
pub enum AppliedPatchFileChange {
|
| 295 |
+
Add {
|
| 296 |
+
content: String,
|
| 297 |
+
overwritten_content: Option<String>,
|
| 298 |
+
},
|
| 299 |
+
Delete {
|
| 300 |
+
content: String,
|
| 301 |
+
},
|
| 302 |
+
Update {
|
| 303 |
+
move_path: Option<PathUri>,
|
| 304 |
+
old_content: String,
|
| 305 |
+
overwritten_move_content: Option<String>,
|
| 306 |
+
new_content: String,
|
| 307 |
+
},
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
/// A failed patch application together with the textual mutations that were
|
| 311 |
+
/// definitely committed before the failure was observed.
|
| 312 |
+
#[derive(Debug, Error)]
|
| 313 |
+
#[error("{error}")]
|
| 314 |
+
pub struct ApplyPatchFailure {
|
| 315 |
+
#[source]
|
| 316 |
+
error: ApplyPatchError,
|
| 317 |
+
delta: AppliedPatchDelta,
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
impl ApplyPatchFailure {
|
| 321 |
+
fn new(error: ApplyPatchError, delta: AppliedPatchDelta) -> Self {
|
| 322 |
+
Self { error, delta }
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
fn without_delta(error: ApplyPatchError) -> Self {
|
| 326 |
+
Self::new(error, AppliedPatchDelta::empty())
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
pub fn delta(&self) -> &AppliedPatchDelta {
|
| 330 |
+
&self.delta
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
pub fn into_parts(self) -> (ApplyPatchError, AppliedPatchDelta) {
|
| 334 |
+
(self.error, self.delta)
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
/// Applies the patch and prints the result to stdout/stderr.
|
| 339 |
+
pub async fn apply_patch(
|
| 340 |
+
patch: &str,
|
| 341 |
+
cwd: &PathUri,
|
| 342 |
+
stdout: &mut impl std::io::Write,
|
| 343 |
+
stderr: &mut impl std::io::Write,
|
| 344 |
+
fs: &dyn ExecutorFileSystem,
|
| 345 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 346 |
+
) -> Result<AppliedPatchDelta, ApplyPatchFailure> {
|
| 347 |
+
apply_patch_with_options(
|
| 348 |
+
patch,
|
| 349 |
+
ApplyPatchOptions::default(),
|
| 350 |
+
cwd,
|
| 351 |
+
stdout,
|
| 352 |
+
stderr,
|
| 353 |
+
fs,
|
| 354 |
+
sandbox,
|
| 355 |
+
)
|
| 356 |
+
.await
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
/// Applies the patch using the selected options and prints the result
|
| 360 |
+
/// to stdout/stderr.
|
| 361 |
+
pub async fn apply_patch_with_options(
|
| 362 |
+
patch: &str,
|
| 363 |
+
options: ApplyPatchOptions,
|
| 364 |
+
cwd: &PathUri,
|
| 365 |
+
stdout: &mut impl std::io::Write,
|
| 366 |
+
stderr: &mut impl std::io::Write,
|
| 367 |
+
fs: &dyn ExecutorFileSystem,
|
| 368 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 369 |
+
) -> Result<AppliedPatchDelta, ApplyPatchFailure> {
|
| 370 |
+
let hunks = match parse_patch(patch) {
|
| 371 |
+
Ok(source) => source.hunks,
|
| 372 |
+
Err(e) => {
|
| 373 |
+
match &e {
|
| 374 |
+
InvalidPatchError(message) => {
|
| 375 |
+
writeln!(stderr, "Invalid patch: {message}")
|
| 376 |
+
.map_err(ApplyPatchError::from)
|
| 377 |
+
.map_err(ApplyPatchFailure::without_delta)?;
|
| 378 |
+
}
|
| 379 |
+
InvalidHunkError {
|
| 380 |
+
message,
|
| 381 |
+
line_number,
|
| 382 |
+
} => {
|
| 383 |
+
writeln!(
|
| 384 |
+
stderr,
|
| 385 |
+
"Invalid patch hunk on line {line_number}: {message}"
|
| 386 |
+
)
|
| 387 |
+
.map_err(ApplyPatchError::from)
|
| 388 |
+
.map_err(ApplyPatchFailure::without_delta)?;
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
return Err(ApplyPatchFailure::without_delta(
|
| 392 |
+
ApplyPatchError::ParseError(e),
|
| 393 |
+
));
|
| 394 |
+
}
|
| 395 |
+
};
|
| 396 |
+
|
| 397 |
+
apply_hunks_with_options(&hunks, options, cwd, stdout, stderr, fs, sandbox).await
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
/// Applies hunks and continues to update stdout/stderr
|
| 401 |
+
pub async fn apply_hunks(
|
| 402 |
+
hunks: &[Hunk],
|
| 403 |
+
cwd: &PathUri,
|
| 404 |
+
stdout: &mut impl std::io::Write,
|
| 405 |
+
stderr: &mut impl std::io::Write,
|
| 406 |
+
fs: &dyn ExecutorFileSystem,
|
| 407 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 408 |
+
) -> Result<AppliedPatchDelta, ApplyPatchFailure> {
|
| 409 |
+
apply_hunks_with_options(
|
| 410 |
+
hunks,
|
| 411 |
+
ApplyPatchOptions::default(),
|
| 412 |
+
cwd,
|
| 413 |
+
stdout,
|
| 414 |
+
stderr,
|
| 415 |
+
fs,
|
| 416 |
+
sandbox,
|
| 417 |
+
)
|
| 418 |
+
.await
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
/// Applies hunks using the selected file-update mode and continues to update
|
| 422 |
+
/// stdout/stderr.
|
| 423 |
+
async fn apply_hunks_with_options(
|
| 424 |
+
hunks: &[Hunk],
|
| 425 |
+
options: ApplyPatchOptions,
|
| 426 |
+
cwd: &PathUri,
|
| 427 |
+
stdout: &mut impl std::io::Write,
|
| 428 |
+
stderr: &mut impl std::io::Write,
|
| 429 |
+
fs: &dyn ExecutorFileSystem,
|
| 430 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 431 |
+
) -> Result<AppliedPatchDelta, ApplyPatchFailure> {
|
| 432 |
+
let mut delta = AppliedPatchDelta::empty();
|
| 433 |
+
match apply_hunks_to_files(hunks, options, cwd, fs, sandbox, &mut delta).await {
|
| 434 |
+
Ok(affected_paths) => {
|
| 435 |
+
print_summary(&affected_paths, stdout).map_err(|error| {
|
| 436 |
+
ApplyPatchFailure::new(ApplyPatchError::from(error), delta.clone())
|
| 437 |
+
})?;
|
| 438 |
+
Ok(delta)
|
| 439 |
+
}
|
| 440 |
+
Err(error) => {
|
| 441 |
+
let msg = error.to_string();
|
| 442 |
+
writeln!(stderr, "{msg}").map_err(|error| {
|
| 443 |
+
ApplyPatchFailure::new(ApplyPatchError::from(error), delta.clone())
|
| 444 |
+
})?;
|
| 445 |
+
let error = if let Some(io) = error.downcast_ref::<std::io::Error>() {
|
| 446 |
+
ApplyPatchError::from(io)
|
| 447 |
+
} else {
|
| 448 |
+
ApplyPatchError::IoError(IoError {
|
| 449 |
+
context: msg,
|
| 450 |
+
source: std::io::Error::other(error),
|
| 451 |
+
})
|
| 452 |
+
};
|
| 453 |
+
Err(ApplyPatchFailure::new(error, delta))
|
| 454 |
+
}
|
| 455 |
+
}
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
/// Applies each parsed patch hunk to the filesystem.
|
| 459 |
+
/// Returns an error if any of the changes could not be applied.
|
| 460 |
+
/// Tracks file paths affected by applying a patch, preserving the path spelling
|
| 461 |
+
/// from the patch for user-facing summaries.
|
| 462 |
+
pub struct AffectedPaths {
|
| 463 |
+
pub added: Vec<PathBuf>,
|
| 464 |
+
pub modified: Vec<PathBuf>,
|
| 465 |
+
pub deleted: Vec<PathBuf>,
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
/// Apply the hunks to the filesystem, returning which files were added, modified, or deleted.
|
| 469 |
+
/// Returns an error if the patch could not be applied.
|
| 470 |
+
async fn apply_hunks_to_files(
|
| 471 |
+
hunks: &[Hunk],
|
| 472 |
+
options: ApplyPatchOptions,
|
| 473 |
+
cwd: &PathUri,
|
| 474 |
+
fs: &dyn ExecutorFileSystem,
|
| 475 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 476 |
+
delta: &mut AppliedPatchDelta,
|
| 477 |
+
) -> anyhow::Result<AffectedPaths> {
|
| 478 |
+
let ApplyPatchOptions {
|
| 479 |
+
update_file_mode,
|
| 480 |
+
follow_symlinks,
|
| 481 |
+
} = options;
|
| 482 |
+
if hunks.is_empty() {
|
| 483 |
+
anyhow::bail!("No files were modified.");
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
let mut added: Vec<PathBuf> = Vec::new();
|
| 487 |
+
let mut modified: Vec<PathBuf> = Vec::new();
|
| 488 |
+
let mut deleted: Vec<PathBuf> = Vec::new();
|
| 489 |
+
// A failed write can still have modified the target before surfacing an
|
| 490 |
+
// error (for example by truncating before ENOSPC), so the accumulated
|
| 491 |
+
// delta is no longer exact when a write fails.
|
| 492 |
+
macro_rules! try_write {
|
| 493 |
+
($result:expr) => {
|
| 494 |
+
match $result {
|
| 495 |
+
Ok(value) => value,
|
| 496 |
+
Err(error) => {
|
| 497 |
+
delta.exact = false;
|
| 498 |
+
return Err(anyhow::Error::from(error));
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
};
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
for hunk in hunks {
|
| 505 |
+
let affected_path = hunk.path().to_path_buf();
|
| 506 |
+
let path_uri = hunk.resolve_path(cwd)?;
|
| 507 |
+
match hunk {
|
| 508 |
+
Hunk::AddFile { contents, .. } => {
|
| 509 |
+
let overwritten_content = read_optional_file_text_for_delta(
|
| 510 |
+
&path_uri,
|
| 511 |
+
fs,
|
| 512 |
+
follow_symlinks,
|
| 513 |
+
sandbox,
|
| 514 |
+
&mut delta.exact,
|
| 515 |
+
)
|
| 516 |
+
.await;
|
| 517 |
+
try_write!(
|
| 518 |
+
write_file_with_missing_parent_retry(
|
| 519 |
+
fs,
|
| 520 |
+
&path_uri,
|
| 521 |
+
contents.clone().into_bytes(),
|
| 522 |
+
follow_symlinks,
|
| 523 |
+
sandbox,
|
| 524 |
+
)
|
| 525 |
+
.await
|
| 526 |
+
);
|
| 527 |
+
delta.changes.push(AppliedPatchChange {
|
| 528 |
+
path: path_uri,
|
| 529 |
+
change: AppliedPatchFileChange::Add {
|
| 530 |
+
content: contents.clone(),
|
| 531 |
+
overwritten_content,
|
| 532 |
+
},
|
| 533 |
+
});
|
| 534 |
+
added.push(affected_path);
|
| 535 |
+
}
|
| 536 |
+
Hunk::DeleteFile { .. } => {
|
| 537 |
+
note_existing_path_delta_support(
|
| 538 |
+
&path_uri,
|
| 539 |
+
fs,
|
| 540 |
+
follow_symlinks,
|
| 541 |
+
sandbox,
|
| 542 |
+
&mut delta.exact,
|
| 543 |
+
)
|
| 544 |
+
.await;
|
| 545 |
+
let deleted_content = fs
|
| 546 |
+
.read_file_text(&path_uri, ReadFileOptions { follow_symlinks }, sandbox)
|
| 547 |
+
.await
|
| 548 |
+
.ok();
|
| 549 |
+
if deleted_content.is_none() {
|
| 550 |
+
delta.exact = false;
|
| 551 |
+
}
|
| 552 |
+
ensure_not_directory(&path_uri, fs, follow_symlinks, sandbox)
|
| 553 |
+
.await
|
| 554 |
+
.with_context(|| {
|
| 555 |
+
format!(
|
| 556 |
+
"Failed to delete file {}",
|
| 557 |
+
path_uri.inferred_native_path_string()
|
| 558 |
+
)
|
| 559 |
+
})?;
|
| 560 |
+
if let Err(error) = fs
|
| 561 |
+
.remove(
|
| 562 |
+
&path_uri,
|
| 563 |
+
RemoveOptions {
|
| 564 |
+
recursive: false,
|
| 565 |
+
force: false,
|
| 566 |
+
follow_symlinks,
|
| 567 |
+
},
|
| 568 |
+
sandbox,
|
| 569 |
+
)
|
| 570 |
+
.await
|
| 571 |
+
.with_context(|| {
|
| 572 |
+
format!(
|
| 573 |
+
"Failed to delete file {}",
|
| 574 |
+
path_uri.inferred_native_path_string()
|
| 575 |
+
)
|
| 576 |
+
})
|
| 577 |
+
{
|
| 578 |
+
delta.exact &= remove_failure_was_side_effect_free(
|
| 579 |
+
&path_uri,
|
| 580 |
+
deleted_content.as_deref(),
|
| 581 |
+
fs,
|
| 582 |
+
follow_symlinks,
|
| 583 |
+
sandbox,
|
| 584 |
+
)
|
| 585 |
+
.await;
|
| 586 |
+
return Err(error);
|
| 587 |
+
}
|
| 588 |
+
if let Some(content) = deleted_content {
|
| 589 |
+
delta.changes.push(AppliedPatchChange {
|
| 590 |
+
path: path_uri,
|
| 591 |
+
change: AppliedPatchFileChange::Delete { content },
|
| 592 |
+
});
|
| 593 |
+
}
|
| 594 |
+
deleted.push(affected_path);
|
| 595 |
+
}
|
| 596 |
+
Hunk::UpdateFile {
|
| 597 |
+
move_path, chunks, ..
|
| 598 |
+
} => {
|
| 599 |
+
note_existing_path_delta_support(
|
| 600 |
+
&path_uri,
|
| 601 |
+
fs,
|
| 602 |
+
follow_symlinks,
|
| 603 |
+
sandbox,
|
| 604 |
+
&mut delta.exact,
|
| 605 |
+
)
|
| 606 |
+
.await;
|
| 607 |
+
let AppliedPatch {
|
| 608 |
+
original_contents,
|
| 609 |
+
new_contents,
|
| 610 |
+
} = derive_new_contents_from_chunks(
|
| 611 |
+
&path_uri,
|
| 612 |
+
chunks,
|
| 613 |
+
update_file_mode,
|
| 614 |
+
fs,
|
| 615 |
+
follow_symlinks,
|
| 616 |
+
sandbox,
|
| 617 |
+
)
|
| 618 |
+
.await?;
|
| 619 |
+
if let Some(dest) = move_path {
|
| 620 |
+
let dest_uri = cwd.join(&dest.to_string_lossy())?;
|
| 621 |
+
let overwritten_move_content = read_optional_file_text_for_delta(
|
| 622 |
+
&dest_uri,
|
| 623 |
+
fs,
|
| 624 |
+
follow_symlinks,
|
| 625 |
+
sandbox,
|
| 626 |
+
&mut delta.exact,
|
| 627 |
+
)
|
| 628 |
+
.await;
|
| 629 |
+
try_write!(
|
| 630 |
+
write_file_with_missing_parent_retry(
|
| 631 |
+
fs,
|
| 632 |
+
&dest_uri,
|
| 633 |
+
new_contents.clone().into_bytes(),
|
| 634 |
+
follow_symlinks,
|
| 635 |
+
sandbox,
|
| 636 |
+
)
|
| 637 |
+
.await
|
| 638 |
+
);
|
| 639 |
+
let dest_write_change_index = delta.changes.len();
|
| 640 |
+
delta.changes.push(AppliedPatchChange {
|
| 641 |
+
path: dest_uri.clone(),
|
| 642 |
+
change: AppliedPatchFileChange::Add {
|
| 643 |
+
content: new_contents.clone(),
|
| 644 |
+
overwritten_content: overwritten_move_content.clone(),
|
| 645 |
+
},
|
| 646 |
+
});
|
| 647 |
+
ensure_not_directory(&path_uri, fs, follow_symlinks, sandbox)
|
| 648 |
+
.await
|
| 649 |
+
.with_context(|| {
|
| 650 |
+
format!(
|
| 651 |
+
"Failed to remove original {}",
|
| 652 |
+
path_uri.inferred_native_path_string()
|
| 653 |
+
)
|
| 654 |
+
})?;
|
| 655 |
+
if let Err(error) = fs
|
| 656 |
+
.remove(
|
| 657 |
+
&path_uri,
|
| 658 |
+
RemoveOptions {
|
| 659 |
+
recursive: false,
|
| 660 |
+
force: false,
|
| 661 |
+
follow_symlinks,
|
| 662 |
+
},
|
| 663 |
+
sandbox,
|
| 664 |
+
)
|
| 665 |
+
.await
|
| 666 |
+
.with_context(|| {
|
| 667 |
+
format!(
|
| 668 |
+
"Failed to remove original {}",
|
| 669 |
+
path_uri.inferred_native_path_string()
|
| 670 |
+
)
|
| 671 |
+
})
|
| 672 |
+
{
|
| 673 |
+
delta.exact &= remove_failure_was_side_effect_free(
|
| 674 |
+
&path_uri,
|
| 675 |
+
Some(&original_contents),
|
| 676 |
+
fs,
|
| 677 |
+
follow_symlinks,
|
| 678 |
+
sandbox,
|
| 679 |
+
)
|
| 680 |
+
.await;
|
| 681 |
+
return Err(error);
|
| 682 |
+
}
|
| 683 |
+
delta.changes[dest_write_change_index] = AppliedPatchChange {
|
| 684 |
+
path: path_uri,
|
| 685 |
+
change: AppliedPatchFileChange::Update {
|
| 686 |
+
move_path: Some(dest_uri),
|
| 687 |
+
old_content: original_contents,
|
| 688 |
+
overwritten_move_content,
|
| 689 |
+
new_content: new_contents,
|
| 690 |
+
},
|
| 691 |
+
};
|
| 692 |
+
modified.push(affected_path);
|
| 693 |
+
} else {
|
| 694 |
+
try_write!(
|
| 695 |
+
fs.write_file(
|
| 696 |
+
&path_uri,
|
| 697 |
+
new_contents.clone().into_bytes(),
|
| 698 |
+
WriteFileOptions { follow_symlinks },
|
| 699 |
+
sandbox,
|
| 700 |
+
)
|
| 701 |
+
.await
|
| 702 |
+
.with_context(|| format!(
|
| 703 |
+
"Failed to write file {}",
|
| 704 |
+
path_uri.inferred_native_path_string()
|
| 705 |
+
))
|
| 706 |
+
);
|
| 707 |
+
delta.changes.push(AppliedPatchChange {
|
| 708 |
+
path: path_uri,
|
| 709 |
+
change: AppliedPatchFileChange::Update {
|
| 710 |
+
move_path: None,
|
| 711 |
+
old_content: original_contents,
|
| 712 |
+
overwritten_move_content: None,
|
| 713 |
+
new_content: new_contents,
|
| 714 |
+
},
|
| 715 |
+
});
|
| 716 |
+
modified.push(affected_path);
|
| 717 |
+
}
|
| 718 |
+
}
|
| 719 |
+
}
|
| 720 |
+
}
|
| 721 |
+
Ok(AffectedPaths {
|
| 722 |
+
added,
|
| 723 |
+
modified,
|
| 724 |
+
deleted,
|
| 725 |
+
})
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
async fn ensure_not_directory(
|
| 729 |
+
path: &PathUri,
|
| 730 |
+
fs: &dyn ExecutorFileSystem,
|
| 731 |
+
follow_symlinks: bool,
|
| 732 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 733 |
+
) -> io::Result<()> {
|
| 734 |
+
let metadata = fs
|
| 735 |
+
.get_metadata(path, GetMetadataOptions { follow_symlinks }, sandbox)
|
| 736 |
+
.await?;
|
| 737 |
+
if metadata.is_directory {
|
| 738 |
+
return Err(io::Error::new(
|
| 739 |
+
io::ErrorKind::InvalidInput,
|
| 740 |
+
"path is a directory",
|
| 741 |
+
));
|
| 742 |
+
}
|
| 743 |
+
Ok(())
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
async fn remove_failure_was_side_effect_free(
|
| 747 |
+
path: &PathUri,
|
| 748 |
+
expected_content: Option<&str>,
|
| 749 |
+
fs: &dyn ExecutorFileSystem,
|
| 750 |
+
follow_symlinks: bool,
|
| 751 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 752 |
+
) -> bool {
|
| 753 |
+
match expected_content {
|
| 754 |
+
Some(expected_content) => fs
|
| 755 |
+
.read_file_text(path, ReadFileOptions { follow_symlinks }, sandbox)
|
| 756 |
+
.await
|
| 757 |
+
.is_ok_and(|content| content == expected_content),
|
| 758 |
+
None => false,
|
| 759 |
+
}
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
async fn read_optional_file_text_for_delta(
|
| 763 |
+
path: &PathUri,
|
| 764 |
+
fs: &dyn ExecutorFileSystem,
|
| 765 |
+
follow_symlinks: bool,
|
| 766 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 767 |
+
exact: &mut bool,
|
| 768 |
+
) -> Option<String> {
|
| 769 |
+
note_existing_path_delta_support(path, fs, follow_symlinks, sandbox, exact).await;
|
| 770 |
+
match fs
|
| 771 |
+
.read_file_text(path, ReadFileOptions { follow_symlinks }, sandbox)
|
| 772 |
+
.await
|
| 773 |
+
{
|
| 774 |
+
Ok(content) => Some(content),
|
| 775 |
+
Err(source) if source.kind() == io::ErrorKind::NotFound => None,
|
| 776 |
+
Err(_) => {
|
| 777 |
+
*exact = false;
|
| 778 |
+
None
|
| 779 |
+
}
|
| 780 |
+
}
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
async fn note_existing_path_delta_support(
|
| 784 |
+
path: &PathUri,
|
| 785 |
+
fs: &dyn ExecutorFileSystem,
|
| 786 |
+
follow_symlinks: bool,
|
| 787 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 788 |
+
exact: &mut bool,
|
| 789 |
+
) {
|
| 790 |
+
match fs
|
| 791 |
+
.get_metadata(path, GetMetadataOptions { follow_symlinks }, sandbox)
|
| 792 |
+
.await
|
| 793 |
+
{
|
| 794 |
+
Ok(metadata) if metadata.is_file && !metadata.is_symlink => {}
|
| 795 |
+
Ok(_) => *exact = false,
|
| 796 |
+
Err(source) if source.kind() == io::ErrorKind::NotFound => {}
|
| 797 |
+
Err(_) => *exact = false,
|
| 798 |
+
}
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
async fn write_file_with_missing_parent_retry(
|
| 802 |
+
fs: &dyn ExecutorFileSystem,
|
| 803 |
+
path: &PathUri,
|
| 804 |
+
contents: Vec<u8>,
|
| 805 |
+
follow_symlinks: bool,
|
| 806 |
+
sandbox: Option<&FileSystemSandboxContext>,
|
| 807 |
+
) -> anyhow::Result<()> {
|
| 808 |
+
match fs
|
| 809 |
+
.write_file(
|
| 810 |
+
path,
|
| 811 |
+
contents.clone(),
|
| 812 |
+
WriteFileOptions { follow_symlinks },
|
| 813 |
+
sandbox,
|
| 814 |
+
)
|
| 815 |
+
.await
|
| 816 |
+
{
|
| 817 |
+
Ok(()) => Ok(()),
|
| 818 |
+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
| 819 |
+
if let Some(parent) = path.parent() {
|
| 820 |
+
fs.create_directory(
|
| 821 |
+
&parent,
|
| 822 |
+
CreateDirectoryOptions {
|
| 823 |
+
recursive: true,
|
| 824 |
+
follow_symlinks,
|
| 825 |
+
},
|
| 826 |
+
sandbox,
|
| 827 |
+
)
|
| 828 |
+
.await
|
| 829 |
+
.with_context(|| {
|
| 830 |
+
format!(
|
| 831 |
+
"Failed to create parent directories for {}",
|
| 832 |
+
path.inferred_native_path_string()
|
| 833 |
+
)
|
| 834 |
+
})?;
|
| 835 |
+
}
|
| 836 |
+
fs.write_file(
|
| 837 |
+
path,
|
| 838 |
+
contents,
|
| 839 |
+
WriteFileOptions { follow_symlinks },
|
| 840 |
+
sandbox,
|
| 841 |
+
)
|
| 842 |
+
.await
|
| 843 |
+
.with_context(|| {
|
| 844 |
+
format!(
|
| 845 |
+
"Failed to write file {}",
|
| 846 |
+
path.inferred_native_path_string()
|
| 847 |
+
)
|
| 848 |
+
})?;
|
| 849 |
+
Ok(())
|
| 850 |
+
}
|
| 851 |
+
Err(err) => Err(err).with_context(|| {
|
| 852 |
+
format!(
|
| 853 |
+
"Failed to write file {}",
|
| 854 |
+
path.inferred_native_path_string()
|
| 855 |
+
)
|
| 856 |
+
}),
|
| 857 |
+
}
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
/// Print the summary of changes in git-style format.
|
| 861 |
+
/// Write a summary of changes to the given writer.
|
| 862 |
+
pub fn print_summary(
|
| 863 |
+
affected: &AffectedPaths,
|
| 864 |
+
out: &mut impl std::io::Write,
|
| 865 |
+
) -> std::io::Result<()> {
|
| 866 |
+
writeln!(out, "Success. Updated the following files:")?;
|
| 867 |
+
for path in &affected.added {
|
| 868 |
+
writeln!(out, "A {}", path.display())?;
|
| 869 |
+
}
|
| 870 |
+
for path in &affected.modified {
|
| 871 |
+
writeln!(out, "M {}", path.display())?;
|
| 872 |
+
}
|
| 873 |
+
for path in &affected.deleted {
|
| 874 |
+
writeln!(out, "D {}", path.display())?;
|
| 875 |
+
}
|
| 876 |
+
Ok(())
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
#[cfg(test)]
|
| 880 |
+
mod tests {
|
| 881 |
+
use super::*;
|
| 882 |
+
use codex_exec_server::LOCAL_FS;
|
| 883 |
+
use pretty_assertions::assert_eq;
|
| 884 |
+
use std::fs;
|
| 885 |
+
use tempfile::tempdir;
|
| 886 |
+
|
| 887 |
+
/// Helper to construct a patch with the given body.
|
| 888 |
+
fn wrap_patch(body: &str) -> String {
|
| 889 |
+
format!("*** Begin Patch\n{body}\n*** End Patch")
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
#[tokio::test]
|
| 893 |
+
async fn test_add_file_hunk_creates_file_with_contents() {
|
| 894 |
+
let dir = tempdir().unwrap();
|
| 895 |
+
let path = dir.path().join("add.txt");
|
| 896 |
+
let patch = wrap_patch(&format!(
|
| 897 |
+
r#"*** Add File: {}
|
| 898 |
+
+ab
|
| 899 |
+
+cd"#,
|
| 900 |
+
path.display()
|
| 901 |
+
));
|
| 902 |
+
let mut stdout = Vec::new();
|
| 903 |
+
let mut stderr = Vec::new();
|
| 904 |
+
apply_patch(
|
| 905 |
+
&patch,
|
| 906 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 907 |
+
&mut stdout,
|
| 908 |
+
&mut stderr,
|
| 909 |
+
LOCAL_FS.as_ref(),
|
| 910 |
+
/*sandbox*/ None,
|
| 911 |
+
)
|
| 912 |
+
.await
|
| 913 |
+
.unwrap();
|
| 914 |
+
// Verify expected stdout and stderr outputs.
|
| 915 |
+
let stdout_str = String::from_utf8(stdout).unwrap();
|
| 916 |
+
let stderr_str = String::from_utf8(stderr).unwrap();
|
| 917 |
+
let expected_out = format!(
|
| 918 |
+
"Success. Updated the following files:\nA {}\n",
|
| 919 |
+
path.display()
|
| 920 |
+
);
|
| 921 |
+
assert_eq!(stdout_str, expected_out);
|
| 922 |
+
assert_eq!(stderr_str, "");
|
| 923 |
+
let contents = fs::read_to_string(path).unwrap();
|
| 924 |
+
assert_eq!(contents, "ab\ncd\n");
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
#[tokio::test]
|
| 928 |
+
async fn test_apply_patch_hunks_accept_relative_and_absolute_paths() {
|
| 929 |
+
let dir = tempdir().unwrap();
|
| 930 |
+
let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute test path");
|
| 931 |
+
let relative_add = dir.path().join("relative-add.txt");
|
| 932 |
+
let absolute_add = dir.path().join("absolute-add.txt");
|
| 933 |
+
let relative_delete = dir.path().join("relative-delete.txt");
|
| 934 |
+
let absolute_delete = dir.path().join("absolute-delete.txt");
|
| 935 |
+
let relative_update = dir.path().join("relative-update.txt");
|
| 936 |
+
let absolute_update = dir.path().join("absolute-update.txt");
|
| 937 |
+
fs::write(&relative_delete, "delete relative\n").unwrap();
|
| 938 |
+
fs::write(&absolute_delete, "delete absolute\n").unwrap();
|
| 939 |
+
fs::write(&relative_update, "relative old\n").unwrap();
|
| 940 |
+
fs::write(&absolute_update, "absolute old\n").unwrap();
|
| 941 |
+
|
| 942 |
+
let patch = wrap_patch(&format!(
|
| 943 |
+
r#"*** Add File: relative-add.txt
|
| 944 |
+
+relative add
|
| 945 |
+
*** Add File: {}
|
| 946 |
+
+absolute add
|
| 947 |
+
*** Delete File: relative-delete.txt
|
| 948 |
+
*** Delete File: {}
|
| 949 |
+
*** Update File: relative-update.txt
|
| 950 |
+
@@
|
| 951 |
+
-relative old
|
| 952 |
+
+relative new
|
| 953 |
+
*** Update File: {}
|
| 954 |
+
@@
|
| 955 |
+
-absolute old
|
| 956 |
+
+absolute new"#,
|
| 957 |
+
absolute_add.display(),
|
| 958 |
+
absolute_delete.display(),
|
| 959 |
+
absolute_update.display(),
|
| 960 |
+
));
|
| 961 |
+
let mut stdout = Vec::new();
|
| 962 |
+
let mut stderr = Vec::new();
|
| 963 |
+
|
| 964 |
+
apply_patch(
|
| 965 |
+
&patch,
|
| 966 |
+
&cwd,
|
| 967 |
+
&mut stdout,
|
| 968 |
+
&mut stderr,
|
| 969 |
+
LOCAL_FS.as_ref(),
|
| 970 |
+
/*sandbox*/ None,
|
| 971 |
+
)
|
| 972 |
+
.await
|
| 973 |
+
.unwrap();
|
| 974 |
+
|
| 975 |
+
assert_eq!(fs::read_to_string(&relative_add).unwrap(), "relative add\n");
|
| 976 |
+
assert_eq!(fs::read_to_string(&absolute_add).unwrap(), "absolute add\n");
|
| 977 |
+
assert!(!relative_delete.exists());
|
| 978 |
+
assert!(!absolute_delete.exists());
|
| 979 |
+
assert_eq!(
|
| 980 |
+
fs::read_to_string(&relative_update).unwrap(),
|
| 981 |
+
"relative new\n"
|
| 982 |
+
);
|
| 983 |
+
assert_eq!(
|
| 984 |
+
fs::read_to_string(&absolute_update).unwrap(),
|
| 985 |
+
"absolute new\n"
|
| 986 |
+
);
|
| 987 |
+
assert_eq!(String::from_utf8(stderr).unwrap(), "");
|
| 988 |
+
assert_eq!(
|
| 989 |
+
String::from_utf8(stdout).unwrap(),
|
| 990 |
+
format!(
|
| 991 |
+
"Success. Updated the following files:\nA relative-add.txt\nA {}\nM relative-update.txt\nM {}\nD relative-delete.txt\nD {}\n",
|
| 992 |
+
absolute_add.display(),
|
| 993 |
+
absolute_update.display(),
|
| 994 |
+
absolute_delete.display(),
|
| 995 |
+
)
|
| 996 |
+
);
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
#[tokio::test]
|
| 1000 |
+
async fn test_delete_file_hunk_removes_file() {
|
| 1001 |
+
let dir = tempdir().unwrap();
|
| 1002 |
+
let path = dir.path().join("del.txt");
|
| 1003 |
+
fs::write(&path, "x").unwrap();
|
| 1004 |
+
let patch = wrap_patch(&format!("*** Delete File: {}", path.display()));
|
| 1005 |
+
let mut stdout = Vec::new();
|
| 1006 |
+
let mut stderr = Vec::new();
|
| 1007 |
+
apply_patch(
|
| 1008 |
+
&patch,
|
| 1009 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 1010 |
+
&mut stdout,
|
| 1011 |
+
&mut stderr,
|
| 1012 |
+
LOCAL_FS.as_ref(),
|
| 1013 |
+
/*sandbox*/ None,
|
| 1014 |
+
)
|
| 1015 |
+
.await
|
| 1016 |
+
.unwrap();
|
| 1017 |
+
let stdout_str = String::from_utf8(stdout).unwrap();
|
| 1018 |
+
let stderr_str = String::from_utf8(stderr).unwrap();
|
| 1019 |
+
let expected_out = format!(
|
| 1020 |
+
"Success. Updated the following files:\nD {}\n",
|
| 1021 |
+
path.display()
|
| 1022 |
+
);
|
| 1023 |
+
assert_eq!(stdout_str, expected_out);
|
| 1024 |
+
assert_eq!(stderr_str, "");
|
| 1025 |
+
assert!(!path.exists());
|
| 1026 |
+
}
|
| 1027 |
+
|
| 1028 |
+
#[tokio::test]
|
| 1029 |
+
async fn test_update_file_hunk_modifies_content() {
|
| 1030 |
+
let dir = tempdir().unwrap();
|
| 1031 |
+
let path = dir.path().join("update.txt");
|
| 1032 |
+
fs::write(&path, "foo\nbar\n").unwrap();
|
| 1033 |
+
let patch = wrap_patch(&format!(
|
| 1034 |
+
r#"*** Update File: {}
|
| 1035 |
+
@@
|
| 1036 |
+
foo
|
| 1037 |
+
-bar
|
| 1038 |
+
+baz"#,
|
| 1039 |
+
path.display()
|
| 1040 |
+
));
|
| 1041 |
+
let mut stdout = Vec::new();
|
| 1042 |
+
let mut stderr = Vec::new();
|
| 1043 |
+
apply_patch(
|
| 1044 |
+
&patch,
|
| 1045 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 1046 |
+
&mut stdout,
|
| 1047 |
+
&mut stderr,
|
| 1048 |
+
LOCAL_FS.as_ref(),
|
| 1049 |
+
/*sandbox*/ None,
|
| 1050 |
+
)
|
| 1051 |
+
.await
|
| 1052 |
+
.unwrap();
|
| 1053 |
+
// Validate modified file contents and expected stdout/stderr.
|
| 1054 |
+
let stdout_str = String::from_utf8(stdout).unwrap();
|
| 1055 |
+
let stderr_str = String::from_utf8(stderr).unwrap();
|
| 1056 |
+
let expected_out = format!(
|
| 1057 |
+
"Success. Updated the following files:\nM {}\n",
|
| 1058 |
+
path.display()
|
| 1059 |
+
);
|
| 1060 |
+
assert_eq!(stdout_str, expected_out);
|
| 1061 |
+
assert_eq!(stderr_str, "");
|
| 1062 |
+
let contents = fs::read_to_string(&path).unwrap();
|
| 1063 |
+
assert_eq!(contents, "foo\nbaz\n");
|
| 1064 |
+
}
|
| 1065 |
+
|
| 1066 |
+
#[tokio::test]
|
| 1067 |
+
async fn test_update_file_hunk_can_move_file() {
|
| 1068 |
+
let dir = tempdir().unwrap();
|
| 1069 |
+
let src = dir.path().join("src.txt");
|
| 1070 |
+
let dest = dir.path().join("dst.txt");
|
| 1071 |
+
fs::write(&src, "line\n").unwrap();
|
| 1072 |
+
let patch = wrap_patch(&format!(
|
| 1073 |
+
r#"*** Update File: {}
|
| 1074 |
+
*** Move to: {}
|
| 1075 |
+
@@
|
| 1076 |
+
-line
|
| 1077 |
+
+line2"#,
|
| 1078 |
+
src.display(),
|
| 1079 |
+
dest.display()
|
| 1080 |
+
));
|
| 1081 |
+
let mut stdout = Vec::new();
|
| 1082 |
+
let mut stderr = Vec::new();
|
| 1083 |
+
apply_patch(
|
| 1084 |
+
&patch,
|
| 1085 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 1086 |
+
&mut stdout,
|
| 1087 |
+
&mut stderr,
|
| 1088 |
+
LOCAL_FS.as_ref(),
|
| 1089 |
+
/*sandbox*/ None,
|
| 1090 |
+
)
|
| 1091 |
+
.await
|
| 1092 |
+
.unwrap();
|
| 1093 |
+
// Validate move semantics and expected stdout/stderr.
|
| 1094 |
+
let stdout_str = String::from_utf8(stdout).unwrap();
|
| 1095 |
+
let stderr_str = String::from_utf8(stderr).unwrap();
|
| 1096 |
+
let expected_out = format!(
|
| 1097 |
+
"Success. Updated the following files:\nM {}\n",
|
| 1098 |
+
dest.display()
|
| 1099 |
+
);
|
| 1100 |
+
assert_eq!(stdout_str, expected_out);
|
| 1101 |
+
assert_eq!(stderr_str, "");
|
| 1102 |
+
assert!(!src.exists());
|
| 1103 |
+
let contents = fs::read_to_string(&dest).unwrap();
|
| 1104 |
+
assert_eq!(contents, "line2\n");
|
| 1105 |
+
}
|
| 1106 |
+
|
| 1107 |
+
#[cfg(unix)]
|
| 1108 |
+
#[tokio::test]
|
| 1109 |
+
async fn test_failed_move_returns_committed_destination_delta() {
|
| 1110 |
+
use std::os::unix::fs::PermissionsExt;
|
| 1111 |
+
|
| 1112 |
+
let dir = tempdir().unwrap();
|
| 1113 |
+
let source_dir = dir.path().join("locked");
|
| 1114 |
+
let dest_dir = dir.path().join("out");
|
| 1115 |
+
fs::create_dir(&source_dir).unwrap();
|
| 1116 |
+
fs::create_dir(&dest_dir).unwrap();
|
| 1117 |
+
let src = source_dir.join("src.txt");
|
| 1118 |
+
let dest = dest_dir.join("dst.txt");
|
| 1119 |
+
fs::write(&src, "line\n").unwrap();
|
| 1120 |
+
fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o555)).unwrap();
|
| 1121 |
+
|
| 1122 |
+
let patch = wrap_patch(
|
| 1123 |
+
"*** Update File: locked/src.txt\n*** Move to: out/dst.txt\n@@\n-line\n+line2",
|
| 1124 |
+
);
|
| 1125 |
+
let mut stdout = Vec::new();
|
| 1126 |
+
let mut stderr = Vec::new();
|
| 1127 |
+
let failure = apply_patch(
|
| 1128 |
+
&patch,
|
| 1129 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 1130 |
+
&mut stdout,
|
| 1131 |
+
&mut stderr,
|
| 1132 |
+
LOCAL_FS.as_ref(),
|
| 1133 |
+
/*sandbox*/ None,
|
| 1134 |
+
)
|
| 1135 |
+
.await
|
| 1136 |
+
.expect_err("source removal should fail after destination write");
|
| 1137 |
+
|
| 1138 |
+
fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o755)).unwrap();
|
| 1139 |
+
|
| 1140 |
+
assert!(
|
| 1141 |
+
String::from_utf8(stderr)
|
| 1142 |
+
.unwrap()
|
| 1143 |
+
.contains(&format!("Failed to remove original {}", src.display()))
|
| 1144 |
+
);
|
| 1145 |
+
assert_eq!(
|
| 1146 |
+
failure.delta(),
|
| 1147 |
+
&AppliedPatchDelta::new(
|
| 1148 |
+
vec![AppliedPatchChange {
|
| 1149 |
+
path: PathUri::from_host_native_path(&dest).expect("absolute destination path"),
|
| 1150 |
+
change: AppliedPatchFileChange::Add {
|
| 1151 |
+
content: "line2\n".to_string(),
|
| 1152 |
+
overwritten_content: None,
|
| 1153 |
+
},
|
| 1154 |
+
}],
|
| 1155 |
+
/*exact*/ true,
|
| 1156 |
+
)
|
| 1157 |
+
);
|
| 1158 |
+
assert_eq!(fs::read_to_string(src).unwrap(), "line\n");
|
| 1159 |
+
assert_eq!(fs::read_to_string(dest).unwrap(), "line2\n");
|
| 1160 |
+
}
|
| 1161 |
+
|
| 1162 |
+
/// Verify that a single `Update File` hunk with multiple change chunks can update different
|
| 1163 |
+
/// parts of a file and that the file is listed only once in the summary.
|
| 1164 |
+
#[tokio::test]
|
| 1165 |
+
async fn test_multiple_update_chunks_apply_to_single_file() {
|
| 1166 |
+
// Start with a file containing four lines.
|
| 1167 |
+
let dir = tempdir().unwrap();
|
| 1168 |
+
let path = dir.path().join("multi.txt");
|
| 1169 |
+
fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
|
| 1170 |
+
// Construct an update patch with two separate change chunks.
|
| 1171 |
+
// The first chunk uses the line `foo` as context and transforms `bar` into `BAR`.
|
| 1172 |
+
// The second chunk uses `baz` as context and transforms `qux` into `QUX`.
|
| 1173 |
+
let patch = wrap_patch(&format!(
|
| 1174 |
+
r#"*** Update File: {}
|
| 1175 |
+
@@
|
| 1176 |
+
foo
|
| 1177 |
+
-bar
|
| 1178 |
+
+BAR
|
| 1179 |
+
@@
|
| 1180 |
+
baz
|
| 1181 |
+
-qux
|
| 1182 |
+
+QUX"#,
|
| 1183 |
+
path.display()
|
| 1184 |
+
));
|
| 1185 |
+
let mut stdout = Vec::new();
|
| 1186 |
+
let mut stderr = Vec::new();
|
| 1187 |
+
apply_patch(
|
| 1188 |
+
&patch,
|
| 1189 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 1190 |
+
&mut stdout,
|
| 1191 |
+
&mut stderr,
|
| 1192 |
+
LOCAL_FS.as_ref(),
|
| 1193 |
+
/*sandbox*/ None,
|
| 1194 |
+
)
|
| 1195 |
+
.await
|
| 1196 |
+
.unwrap();
|
| 1197 |
+
let stdout_str = String::from_utf8(stdout).unwrap();
|
| 1198 |
+
let stderr_str = String::from_utf8(stderr).unwrap();
|
| 1199 |
+
let expected_out = format!(
|
| 1200 |
+
"Success. Updated the following files:\nM {}\n",
|
| 1201 |
+
path.display()
|
| 1202 |
+
);
|
| 1203 |
+
assert_eq!(stdout_str, expected_out);
|
| 1204 |
+
assert_eq!(stderr_str, "");
|
| 1205 |
+
let contents = fs::read_to_string(&path).unwrap();
|
| 1206 |
+
assert_eq!(contents, "foo\nBAR\nbaz\nQUX\n");
|
| 1207 |
+
}
|
| 1208 |
+
|
| 1209 |
+
/// A more involved `Update File` hunk that exercises additions, deletions and
|
| 1210 |
+
/// replacements in separate chunks that appear in non‑adjacent parts of the
|
| 1211 |
+
/// file. Verifies that all edits are applied and that the summary lists the
|
| 1212 |
+
/// file only once.
|
| 1213 |
+
#[tokio::test]
|
| 1214 |
+
async fn test_update_file_hunk_interleaved_changes() {
|
| 1215 |
+
let dir = tempdir().unwrap();
|
| 1216 |
+
let path = dir.path().join("interleaved.txt");
|
| 1217 |
+
|
| 1218 |
+
// Original file: six numbered lines.
|
| 1219 |
+
fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
|
| 1220 |
+
|
| 1221 |
+
// Patch performs:
|
| 1222 |
+
// • Replace `b` → `B`
|
| 1223 |
+
// • Replace `e` → `E` (using surrounding context)
|
| 1224 |
+
// • Append new line `g` at the end‑of‑file
|
| 1225 |
+
let patch = wrap_patch(&format!(
|
| 1226 |
+
r#"*** Update File: {}
|
| 1227 |
+
@@
|
| 1228 |
+
a
|
| 1229 |
+
-b
|
| 1230 |
+
+B
|
| 1231 |
+
@@
|
| 1232 |
+
c
|
| 1233 |
+
d
|
| 1234 |
+
-e
|
| 1235 |
+
+E
|
| 1236 |
+
@@
|
| 1237 |
+
f
|
| 1238 |
+
+g
|
| 1239 |
+
*** End of File"#,
|
| 1240 |
+
path.display()
|
| 1241 |
+
));
|
| 1242 |
+
|
| 1243 |
+
let mut stdout = Vec::new();
|
| 1244 |
+
let mut stderr = Vec::new();
|
| 1245 |
+
apply_patch(
|
| 1246 |
+
&patch,
|
| 1247 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 1248 |
+
&mut stdout,
|
| 1249 |
+
&mut stderr,
|
| 1250 |
+
LOCAL_FS.as_ref(),
|
| 1251 |
+
/*sandbox*/ None,
|
| 1252 |
+
)
|
| 1253 |
+
.await
|
| 1254 |
+
.unwrap();
|
| 1255 |
+
|
| 1256 |
+
let stdout_str = String::from_utf8(stdout).unwrap();
|
| 1257 |
+
let stderr_str = String::from_utf8(stderr).unwrap();
|
| 1258 |
+
|
| 1259 |
+
let expected_out = format!(
|
| 1260 |
+
"Success. Updated the following files:\nM {}\n",
|
| 1261 |
+
path.display()
|
| 1262 |
+
);
|
| 1263 |
+
assert_eq!(stdout_str, expected_out);
|
| 1264 |
+
assert_eq!(stderr_str, "");
|
| 1265 |
+
|
| 1266 |
+
let contents = fs::read_to_string(&path).unwrap();
|
| 1267 |
+
assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n");
|
| 1268 |
+
}
|
| 1269 |
+
|
| 1270 |
+
#[tokio::test]
|
| 1271 |
+
async fn test_pure_addition_chunk_followed_by_removal() {
|
| 1272 |
+
let dir = tempdir().unwrap();
|
| 1273 |
+
let path = dir.path().join("panic.txt");
|
| 1274 |
+
fs::write(&path, "line1\nline2\nline3\n").unwrap();
|
| 1275 |
+
let patch = wrap_patch(&format!(
|
| 1276 |
+
r#"*** Update File: {}
|
| 1277 |
+
@@
|
| 1278 |
+
+after-context
|
| 1279 |
+
+second-line
|
| 1280 |
+
@@
|
| 1281 |
+
line1
|
| 1282 |
+
-line2
|
| 1283 |
+
-line3
|
| 1284 |
+
+line2-replacement"#,
|
| 1285 |
+
path.display()
|
| 1286 |
+
));
|
| 1287 |
+
let mut stdout = Vec::new();
|
| 1288 |
+
let mut stderr = Vec::new();
|
| 1289 |
+
apply_patch(
|
| 1290 |
+
&patch,
|
| 1291 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 1292 |
+
&mut stdout,
|
| 1293 |
+
&mut stderr,
|
| 1294 |
+
LOCAL_FS.as_ref(),
|
| 1295 |
+
/*sandbox*/ None,
|
| 1296 |
+
)
|
| 1297 |
+
.await
|
| 1298 |
+
.unwrap();
|
| 1299 |
+
let contents = fs::read_to_string(path).unwrap();
|
| 1300 |
+
assert_eq!(
|
| 1301 |
+
contents,
|
| 1302 |
+
"line1\nline2-replacement\nafter-context\nsecond-line\n"
|
| 1303 |
+
);
|
| 1304 |
+
}
|
| 1305 |
+
|
| 1306 |
+
/// Ensure that patches authored with ASCII characters can update lines that
|
| 1307 |
+
/// contain typographic Unicode punctuation (e.g. EN DASH, NON-BREAKING
|
| 1308 |
+
/// HYPHEN). Historically `git apply` succeeds in such scenarios but our
|
| 1309 |
+
/// internal matcher failed requiring an exact byte-for-byte match. The
|
| 1310 |
+
/// fuzzy-matching pass that normalises common punctuation should now bridge
|
| 1311 |
+
/// the gap.
|
| 1312 |
+
#[tokio::test]
|
| 1313 |
+
async fn test_update_line_with_unicode_dash() {
|
| 1314 |
+
let dir = tempdir().unwrap();
|
| 1315 |
+
let path = dir.path().join("unicode.py");
|
| 1316 |
+
|
| 1317 |
+
// Original line contains EN DASH (\u{2013}) and NON-BREAKING HYPHEN (\u{2011}).
|
| 1318 |
+
let original = "import asyncio # local import \u{2013} avoids top\u{2011}level dep\n";
|
| 1319 |
+
std::fs::write(&path, original).unwrap();
|
| 1320 |
+
|
| 1321 |
+
// Patch uses plain ASCII dash / hyphen.
|
| 1322 |
+
let patch = wrap_patch(&format!(
|
| 1323 |
+
r#"*** Update File: {}
|
| 1324 |
+
@@
|
| 1325 |
+
-import asyncio # local import - avoids top-level dep
|
| 1326 |
+
+import asyncio # HELLO"#,
|
| 1327 |
+
path.display()
|
| 1328 |
+
));
|
| 1329 |
+
|
| 1330 |
+
let mut stdout = Vec::new();
|
| 1331 |
+
let mut stderr = Vec::new();
|
| 1332 |
+
apply_patch(
|
| 1333 |
+
&patch,
|
| 1334 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 1335 |
+
&mut stdout,
|
| 1336 |
+
&mut stderr,
|
| 1337 |
+
LOCAL_FS.as_ref(),
|
| 1338 |
+
/*sandbox*/ None,
|
| 1339 |
+
)
|
| 1340 |
+
.await
|
| 1341 |
+
.unwrap();
|
| 1342 |
+
|
| 1343 |
+
// File should now contain the replaced comment.
|
| 1344 |
+
let expected = "import asyncio # HELLO\n";
|
| 1345 |
+
let contents = std::fs::read_to_string(&path).unwrap();
|
| 1346 |
+
assert_eq!(contents, expected);
|
| 1347 |
+
|
| 1348 |
+
// Ensure success summary lists the file as modified.
|
| 1349 |
+
let stdout_str = String::from_utf8(stdout).unwrap();
|
| 1350 |
+
let expected_out = format!(
|
| 1351 |
+
"Success. Updated the following files:\nM {}\n",
|
| 1352 |
+
path.display()
|
| 1353 |
+
);
|
| 1354 |
+
assert_eq!(stdout_str, expected_out);
|
| 1355 |
+
|
| 1356 |
+
// No stderr expected.
|
| 1357 |
+
assert_eq!(String::from_utf8(stderr).unwrap(), "");
|
| 1358 |
+
}
|
| 1359 |
+
|
| 1360 |
+
#[cfg(unix)]
|
| 1361 |
+
#[tokio::test]
|
| 1362 |
+
async fn test_apply_patch_fails_on_write_error() {
|
| 1363 |
+
use std::os::unix::fs::PermissionsExt;
|
| 1364 |
+
|
| 1365 |
+
let dir = tempdir().unwrap();
|
| 1366 |
+
let locked_dir = dir.path().join("locked");
|
| 1367 |
+
fs::create_dir(&locked_dir).unwrap();
|
| 1368 |
+
fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o555)).unwrap();
|
| 1369 |
+
|
| 1370 |
+
let patch = wrap_patch("*** Add File: locked/new.txt\n+after");
|
| 1371 |
+
|
| 1372 |
+
let mut stdout = Vec::new();
|
| 1373 |
+
let mut stderr = Vec::new();
|
| 1374 |
+
let result = apply_patch(
|
| 1375 |
+
&patch,
|
| 1376 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 1377 |
+
&mut stdout,
|
| 1378 |
+
&mut stderr,
|
| 1379 |
+
LOCAL_FS.as_ref(),
|
| 1380 |
+
/*sandbox*/ None,
|
| 1381 |
+
)
|
| 1382 |
+
.await;
|
| 1383 |
+
let failure = result.expect_err("write should fail");
|
| 1384 |
+
|
| 1385 |
+
fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o755)).unwrap();
|
| 1386 |
+
|
| 1387 |
+
assert!(!failure.delta().is_exact());
|
| 1388 |
+
}
|
| 1389 |
+
|
| 1390 |
+
#[tokio::test]
|
| 1391 |
+
async fn test_unreadable_destinations_return_inexact_delta() {
|
| 1392 |
+
let dir = tempdir().unwrap();
|
| 1393 |
+
let path = dir.path().join("binary.dat");
|
| 1394 |
+
fs::write(dir.path().join("source.txt"), "before\n").unwrap();
|
| 1395 |
+
let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute test path");
|
| 1396 |
+
|
| 1397 |
+
for patch in [
|
| 1398 |
+
wrap_patch("*** Add File: binary.dat\n+text"),
|
| 1399 |
+
wrap_patch("*** Update File: source.txt\n*** Move to: binary.dat\n@@\n-before\n+after"),
|
| 1400 |
+
] {
|
| 1401 |
+
fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap();
|
| 1402 |
+
let mut stdout = Vec::new();
|
| 1403 |
+
let mut stderr = Vec::new();
|
| 1404 |
+
let delta = apply_patch(
|
| 1405 |
+
&patch,
|
| 1406 |
+
&cwd,
|
| 1407 |
+
&mut stdout,
|
| 1408 |
+
&mut stderr,
|
| 1409 |
+
LOCAL_FS.as_ref(),
|
| 1410 |
+
/*sandbox*/ None,
|
| 1411 |
+
)
|
| 1412 |
+
.await
|
| 1413 |
+
.unwrap();
|
| 1414 |
+
|
| 1415 |
+
assert!(!delta.is_exact());
|
| 1416 |
+
}
|
| 1417 |
+
}
|
| 1418 |
+
|
| 1419 |
+
#[cfg(unix)]
|
| 1420 |
+
#[tokio::test]
|
| 1421 |
+
async fn test_delete_symlink_returns_inexact_delta() {
|
| 1422 |
+
use std::os::unix::fs::symlink;
|
| 1423 |
+
|
| 1424 |
+
let dir = tempdir().unwrap();
|
| 1425 |
+
fs::write(dir.path().join("target.txt"), "target\n").unwrap();
|
| 1426 |
+
symlink(dir.path().join("target.txt"), dir.path().join("link.txt")).unwrap();
|
| 1427 |
+
let patch = wrap_patch("*** Delete File: link.txt");
|
| 1428 |
+
|
| 1429 |
+
let mut stdout = Vec::new();
|
| 1430 |
+
let mut stderr = Vec::new();
|
| 1431 |
+
let delta = apply_patch(
|
| 1432 |
+
&patch,
|
| 1433 |
+
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
| 1434 |
+
&mut stdout,
|
| 1435 |
+
&mut stderr,
|
| 1436 |
+
LOCAL_FS.as_ref(),
|
| 1437 |
+
/*sandbox*/ None,
|
| 1438 |
+
)
|
| 1439 |
+
.await
|
| 1440 |
+
.unwrap();
|
| 1441 |
+
|
| 1442 |
+
assert!(!delta.is_exact());
|
| 1443 |
+
}
|
| 1444 |
+
}
|
codex-rs/apply-patch/src/main.rs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pub fn main() -> ! {
|
| 2 |
+
codex_apply_patch::main()
|
| 3 |
+
}
|
codex-rs/apply-patch/src/parser.rs
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! This module is responsible for parsing & validating a patch into a list of "hunks".
|
| 2 |
+
//! (It does not attempt to actually check that the patch can be applied to the filesystem.)
|
| 3 |
+
//!
|
| 4 |
+
//! The official Lark grammar for the apply-patch format is:
|
| 5 |
+
//!
|
| 6 |
+
//! start: begin_patch environment_id? hunk+ end_patch
|
| 7 |
+
//! begin_patch: "*** Begin Patch" LF
|
| 8 |
+
//! environment_id: "*** Environment ID: " filename LF
|
| 9 |
+
//! end_patch: "*** End Patch" LF?
|
| 10 |
+
//!
|
| 11 |
+
//! hunk: add_hunk | delete_hunk | update_hunk
|
| 12 |
+
//! add_hunk: "*** Add File: " filename LF add_line+
|
| 13 |
+
//! delete_hunk: "*** Delete File: " filename LF
|
| 14 |
+
//! update_hunk: "*** Update File: " filename LF change_move? change?
|
| 15 |
+
//! filename: /(.+)/
|
| 16 |
+
//! add_line: "+" /(.+)/ LF -> line
|
| 17 |
+
//!
|
| 18 |
+
//! change_move: "*** Move to: " filename LF
|
| 19 |
+
//! change: (change_context | change_line)+ eof_line?
|
| 20 |
+
//! change_context: ("@@" | "@@ " /(.+)/) LF
|
| 21 |
+
//! change_line: ("+" | "-" | " ") /(.+)/ LF
|
| 22 |
+
//! eof_line: "*** End of File" LF
|
| 23 |
+
//!
|
| 24 |
+
//! The parser below is a little more lenient than the explicit spec and allows for
|
| 25 |
+
//! leading/trailing whitespace around patch markers.
|
| 26 |
+
use crate::ApplyPatchArgs;
|
| 27 |
+
use crate::streaming_parser::StreamingPatchParser;
|
| 28 |
+
#[cfg(test)]
|
| 29 |
+
use codex_utils_absolute_path::test_support::PathBufExt;
|
| 30 |
+
use codex_utils_path_uri::PathUri;
|
| 31 |
+
use codex_utils_path_uri::PathUriParseError;
|
| 32 |
+
use std::path::Path;
|
| 33 |
+
use std::path::PathBuf;
|
| 34 |
+
|
| 35 |
+
use thiserror::Error;
|
| 36 |
+
|
| 37 |
+
pub(crate) const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
|
| 38 |
+
pub(crate) const END_PATCH_MARKER: &str = "*** End Patch";
|
| 39 |
+
pub(crate) const ADD_FILE_MARKER: &str = "*** Add File: ";
|
| 40 |
+
pub(crate) const DELETE_FILE_MARKER: &str = "*** Delete File: ";
|
| 41 |
+
pub(crate) const UPDATE_FILE_MARKER: &str = "*** Update File: ";
|
| 42 |
+
pub(crate) const MOVE_TO_MARKER: &str = "*** Move to: ";
|
| 43 |
+
pub(crate) const EOF_MARKER: &str = "*** End of File";
|
| 44 |
+
pub(crate) const CHANGE_CONTEXT_MARKER: &str = "@@ ";
|
| 45 |
+
pub(crate) const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
|
| 46 |
+
|
| 47 |
+
/// Currently, the only OpenAI model that knowingly requires lenient parsing is
|
| 48 |
+
/// gpt-4.1. While we could try to require everyone to pass in a strictness
|
| 49 |
+
/// param when invoking apply_patch, it is a pain to thread it through all of
|
| 50 |
+
/// the call sites, so we resign ourselves allowing lenient parsing for all
|
| 51 |
+
/// models. See [`ParseMode::Lenient`] for details on the exceptions we make for
|
| 52 |
+
/// gpt-4.1.
|
| 53 |
+
const PARSE_IN_STRICT_MODE: bool = false;
|
| 54 |
+
|
| 55 |
+
#[derive(Debug, PartialEq, Error, Clone)]
|
| 56 |
+
pub enum ParseError {
|
| 57 |
+
#[error("invalid patch: {0}")]
|
| 58 |
+
InvalidPatchError(String),
|
| 59 |
+
#[error("invalid hunk at line {line_number}, {message}")]
|
| 60 |
+
InvalidHunkError { message: String, line_number: usize },
|
| 61 |
+
}
|
| 62 |
+
use ParseError::*;
|
| 63 |
+
|
| 64 |
+
#[derive(Debug, PartialEq, Clone)]
|
| 65 |
+
#[allow(clippy::enum_variant_names)]
|
| 66 |
+
pub enum Hunk {
|
| 67 |
+
AddFile {
|
| 68 |
+
path: PathBuf,
|
| 69 |
+
contents: String,
|
| 70 |
+
},
|
| 71 |
+
DeleteFile {
|
| 72 |
+
path: PathBuf,
|
| 73 |
+
},
|
| 74 |
+
UpdateFile {
|
| 75 |
+
path: PathBuf,
|
| 76 |
+
move_path: Option<PathBuf>,
|
| 77 |
+
|
| 78 |
+
/// Chunks should be in order, i.e. the `change_context` of one chunk
|
| 79 |
+
/// should occur later in the file than the previous chunk.
|
| 80 |
+
chunks: Vec<UpdateFileChunk>,
|
| 81 |
+
},
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
impl Hunk {
|
| 85 |
+
pub fn resolve_path(&self, cwd: &PathUri) -> Result<PathUri, PathUriParseError> {
|
| 86 |
+
let path = match self {
|
| 87 |
+
Hunk::UpdateFile { path, .. } => path,
|
| 88 |
+
Hunk::AddFile { .. } | Hunk::DeleteFile { .. } => self.path(),
|
| 89 |
+
};
|
| 90 |
+
cwd.join(&path.to_string_lossy())
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/// Returns the path affected by this hunk, using the move destination for rename hunks.
|
| 94 |
+
pub fn path(&self) -> &Path {
|
| 95 |
+
match self {
|
| 96 |
+
Hunk::AddFile { path, .. } => path,
|
| 97 |
+
Hunk::DeleteFile { path } => path,
|
| 98 |
+
Hunk::UpdateFile {
|
| 99 |
+
move_path: Some(path),
|
| 100 |
+
..
|
| 101 |
+
} => path,
|
| 102 |
+
Hunk::UpdateFile {
|
| 103 |
+
path,
|
| 104 |
+
move_path: None,
|
| 105 |
+
..
|
| 106 |
+
} => path,
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
#[cfg(test)]
|
| 112 |
+
use Hunk::*;
|
| 113 |
+
|
| 114 |
+
#[derive(Debug, Default, PartialEq, Clone)]
|
| 115 |
+
pub struct UpdateFileChunk {
|
| 116 |
+
/// A single line of context used to narrow down the position of the chunk
|
| 117 |
+
/// (this is usually a class, method, or function definition.)
|
| 118 |
+
pub change_context: Option<String>,
|
| 119 |
+
|
| 120 |
+
/// A contiguous block of lines that should be replaced with `new_lines`.
|
| 121 |
+
/// `old_lines` must occur strictly after `change_context`.
|
| 122 |
+
pub old_lines: Vec<String>,
|
| 123 |
+
pub new_lines: Vec<String>,
|
| 124 |
+
|
| 125 |
+
/// Pairs of indices into `old_lines` and `new_lines` that identify lines
|
| 126 |
+
/// parsed as context rather than inferred to be equal by their contents.
|
| 127 |
+
pub context_line_indices: Vec<(usize, usize)>,
|
| 128 |
+
|
| 129 |
+
/// If set to true, `old_lines` must occur at the end of the source file.
|
| 130 |
+
/// (Tolerance around trailing newlines should be encouraged.)
|
| 131 |
+
pub is_end_of_file: bool,
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
impl UpdateFileChunk {
|
| 135 |
+
/// Adds a context line to both sides while recording its corresponding
|
| 136 |
+
/// indices so it remains distinguishable from identical changed lines.
|
| 137 |
+
pub(crate) fn push_context_line(&mut self, line: String) {
|
| 138 |
+
self.context_line_indices
|
| 139 |
+
.push((self.old_lines.len(), self.new_lines.len()));
|
| 140 |
+
self.old_lines.push(line.clone());
|
| 141 |
+
self.new_lines.push(line);
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
|
| 146 |
+
let mode = if PARSE_IN_STRICT_MODE {
|
| 147 |
+
ParseMode::Strict
|
| 148 |
+
} else {
|
| 149 |
+
ParseMode::Lenient
|
| 150 |
+
};
|
| 151 |
+
parse_patch_text(patch, mode)
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
enum ParseMode {
|
| 155 |
+
/// Parse the patch text argument as is.
|
| 156 |
+
Strict,
|
| 157 |
+
|
| 158 |
+
/// GPT-4.1 is known to formulate the `command` array for the `local_shell`
|
| 159 |
+
/// tool call for `apply_patch` call using something like the following:
|
| 160 |
+
///
|
| 161 |
+
/// ```json
|
| 162 |
+
/// [
|
| 163 |
+
/// "apply_patch",
|
| 164 |
+
/// "<<'EOF'\n*** Begin Patch\n*** Update File: README.md\n@@...\n*** End Patch\nEOF\n",
|
| 165 |
+
/// ]
|
| 166 |
+
/// ```
|
| 167 |
+
///
|
| 168 |
+
/// This is a problem because `local_shell` is a bit of a misnomer: the
|
| 169 |
+
/// `command` is not invoked by passing the arguments to a shell like Bash,
|
| 170 |
+
/// but are invoked using something akin to `execvpe(3)`.
|
| 171 |
+
///
|
| 172 |
+
/// This is significant in this case because where a shell would interpret
|
| 173 |
+
/// `<<'EOF'...` as a heredoc and pass the contents via stdin (which is
|
| 174 |
+
/// fine, as `apply_patch` is specified to read from stdin if no argument is
|
| 175 |
+
/// passed), `execvpe(3)` interprets the heredoc as a literal string. To get
|
| 176 |
+
/// the `local_shell` tool to run a command the way shell would, the
|
| 177 |
+
/// `command` array must be something like:
|
| 178 |
+
///
|
| 179 |
+
/// ```json
|
| 180 |
+
/// [
|
| 181 |
+
/// "bash",
|
| 182 |
+
/// "-lc",
|
| 183 |
+
/// "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: README.md\n@@...\n*** End Patch\nEOF\n",
|
| 184 |
+
/// ]
|
| 185 |
+
/// ```
|
| 186 |
+
///
|
| 187 |
+
/// In lenient mode, we check if the argument to `apply_patch` starts with
|
| 188 |
+
/// `<<'EOF'` and ends with `EOF\n`. If so, we strip off these markers,
|
| 189 |
+
/// trim() the result, and treat what is left as the patch text.
|
| 190 |
+
Lenient,
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> {
|
| 194 |
+
let lines: Vec<&str> = patch.trim().lines().collect();
|
| 195 |
+
let patch_lines = match mode {
|
| 196 |
+
ParseMode::Strict => check_patch_boundaries_strict(&lines)?,
|
| 197 |
+
ParseMode::Lenient => check_patch_boundaries_lenient(&lines)?,
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
let patch = patch_lines.join("\n");
|
| 201 |
+
let mut parser = StreamingPatchParser::default();
|
| 202 |
+
parser.push_delta(&patch)?;
|
| 203 |
+
let hunks = parser.finish()?;
|
| 204 |
+
let environment_id = parser.environment_id().map(str::to_owned);
|
| 205 |
+
Ok(ApplyPatchArgs {
|
| 206 |
+
hunks,
|
| 207 |
+
patch,
|
| 208 |
+
workdir: None,
|
| 209 |
+
environment_id,
|
| 210 |
+
})
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/// Checks the start and end lines of the patch text for `apply_patch`,
|
| 214 |
+
/// returning an error if they do not match the expected markers.
|
| 215 |
+
fn check_patch_boundaries_strict<'a>(lines: &'a [&'a str]) -> Result<&'a [&'a str], ParseError> {
|
| 216 |
+
let (first_line, last_line) = match lines {
|
| 217 |
+
[] => (None, None),
|
| 218 |
+
[first] => (Some(first), Some(first)),
|
| 219 |
+
[first, .., last] => (Some(first), Some(last)),
|
| 220 |
+
};
|
| 221 |
+
check_start_and_end_lines_strict(first_line, last_line)?;
|
| 222 |
+
Ok(lines)
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/// If we are in lenient mode, we check if the first line starts with `<<EOF`
|
| 226 |
+
/// (possibly quoted) and the last line ends with `EOF`. There must be at least
|
| 227 |
+
/// 4 lines total because the heredoc markers take up 2 lines and the patch text
|
| 228 |
+
/// must have at least 2 lines.
|
| 229 |
+
///
|
| 230 |
+
/// If successful, returns the lines of the patch text that contain the patch
|
| 231 |
+
/// contents, excluding the heredoc markers.
|
| 232 |
+
fn check_patch_boundaries_lenient<'a>(
|
| 233 |
+
original_lines: &'a [&'a str],
|
| 234 |
+
) -> Result<&'a [&'a str], ParseError> {
|
| 235 |
+
let original_parse_error = match check_patch_boundaries_strict(original_lines) {
|
| 236 |
+
Ok(lines) => return Ok(lines),
|
| 237 |
+
Err(e) => e,
|
| 238 |
+
};
|
| 239 |
+
|
| 240 |
+
match original_lines {
|
| 241 |
+
[first, .., last] => {
|
| 242 |
+
if (first == &"<<EOF" || first == &"<<'EOF'" || first == &"<<\"EOF\"")
|
| 243 |
+
&& last.ends_with("EOF")
|
| 244 |
+
&& original_lines.len() >= 4
|
| 245 |
+
{
|
| 246 |
+
let inner_lines = &original_lines[1..original_lines.len() - 1];
|
| 247 |
+
check_patch_boundaries_strict(inner_lines)
|
| 248 |
+
} else {
|
| 249 |
+
Err(original_parse_error)
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
_ => Err(original_parse_error),
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
fn check_start_and_end_lines_strict(
|
| 257 |
+
first_line: Option<&&str>,
|
| 258 |
+
last_line: Option<&&str>,
|
| 259 |
+
) -> Result<(), ParseError> {
|
| 260 |
+
let first_line = first_line.map(|line| line.trim());
|
| 261 |
+
let last_line = last_line.map(|line| line.trim());
|
| 262 |
+
|
| 263 |
+
match (first_line, last_line) {
|
| 264 |
+
(Some(first), Some(last)) if first == BEGIN_PATCH_MARKER && last == END_PATCH_MARKER => {
|
| 265 |
+
Ok(())
|
| 266 |
+
}
|
| 267 |
+
(Some(first), _) if first != BEGIN_PATCH_MARKER => Err(InvalidPatchError(String::from(
|
| 268 |
+
"The first line of the patch must be '*** Begin Patch'",
|
| 269 |
+
))),
|
| 270 |
+
_ => Err(InvalidPatchError(String::from(
|
| 271 |
+
"The last line of the patch must be '*** End Patch'",
|
| 272 |
+
))),
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
#[test]
|
| 277 |
+
fn test_parse_patch() {
|
| 278 |
+
assert_eq!(
|
| 279 |
+
parse_patch_text("bad", ParseMode::Strict),
|
| 280 |
+
Err(InvalidPatchError(
|
| 281 |
+
"The first line of the patch must be '*** Begin Patch'".to_string()
|
| 282 |
+
))
|
| 283 |
+
);
|
| 284 |
+
assert_eq!(
|
| 285 |
+
parse_patch_text("*** Begin Patch\nbad", ParseMode::Strict),
|
| 286 |
+
Err(InvalidPatchError(
|
| 287 |
+
"The last line of the patch must be '*** End Patch'".to_string()
|
| 288 |
+
))
|
| 289 |
+
);
|
| 290 |
+
|
| 291 |
+
assert_eq!(
|
| 292 |
+
parse_patch_text(
|
| 293 |
+
concat!(
|
| 294 |
+
"*** Begin Patch",
|
| 295 |
+
" ",
|
| 296 |
+
"\n*** Add File: foo\n+hi\n",
|
| 297 |
+
" ",
|
| 298 |
+
"*** End Patch"
|
| 299 |
+
),
|
| 300 |
+
ParseMode::Strict
|
| 301 |
+
)
|
| 302 |
+
.unwrap()
|
| 303 |
+
.hunks,
|
| 304 |
+
vec![AddFile {
|
| 305 |
+
path: PathBuf::from("foo"),
|
| 306 |
+
contents: "hi\n".to_string()
|
| 307 |
+
}]
|
| 308 |
+
);
|
| 309 |
+
assert_eq!(
|
| 310 |
+
parse_patch_text(
|
| 311 |
+
"*** Begin Patch\n\
|
| 312 |
+
*** Update File: test.py\n\
|
| 313 |
+
*** End Patch",
|
| 314 |
+
ParseMode::Strict
|
| 315 |
+
),
|
| 316 |
+
Err(InvalidHunkError {
|
| 317 |
+
message: "Update file hunk for path 'test.py' is empty".to_string(),
|
| 318 |
+
line_number: 2,
|
| 319 |
+
})
|
| 320 |
+
);
|
| 321 |
+
assert_eq!(
|
| 322 |
+
parse_patch_text(
|
| 323 |
+
"*** Begin Patch\n\
|
| 324 |
+
*** End Patch",
|
| 325 |
+
ParseMode::Strict
|
| 326 |
+
)
|
| 327 |
+
.unwrap()
|
| 328 |
+
.hunks,
|
| 329 |
+
Vec::new()
|
| 330 |
+
);
|
| 331 |
+
assert_eq!(
|
| 332 |
+
parse_patch_text(
|
| 333 |
+
"*** Begin Patch\n\
|
| 334 |
+
*** Add File: path/add.py\n\
|
| 335 |
+
+abc\n\
|
| 336 |
+
+def\n\
|
| 337 |
+
*** Delete File: path/delete.py\n\
|
| 338 |
+
*** Update File: path/update.py\n\
|
| 339 |
+
*** Move to: path/update2.py\n\
|
| 340 |
+
@@ def f():\n\
|
| 341 |
+
- pass\n\
|
| 342 |
+
+ return 123\n\
|
| 343 |
+
*** End Patch",
|
| 344 |
+
ParseMode::Strict
|
| 345 |
+
)
|
| 346 |
+
.unwrap()
|
| 347 |
+
.hunks,
|
| 348 |
+
vec![
|
| 349 |
+
AddFile {
|
| 350 |
+
path: PathBuf::from("path/add.py"),
|
| 351 |
+
contents: "abc\ndef\n".to_string()
|
| 352 |
+
},
|
| 353 |
+
DeleteFile {
|
| 354 |
+
path: PathBuf::from("path/delete.py")
|
| 355 |
+
},
|
| 356 |
+
UpdateFile {
|
| 357 |
+
path: PathBuf::from("path/update.py"),
|
| 358 |
+
move_path: Some(PathBuf::from("path/update2.py")),
|
| 359 |
+
chunks: vec![UpdateFileChunk {
|
| 360 |
+
change_context: Some("def f():".to_string()),
|
| 361 |
+
old_lines: vec![" pass".to_string()],
|
| 362 |
+
new_lines: vec![" return 123".to_string()],
|
| 363 |
+
context_line_indices: vec![],
|
| 364 |
+
is_end_of_file: false
|
| 365 |
+
}]
|
| 366 |
+
}
|
| 367 |
+
]
|
| 368 |
+
);
|
| 369 |
+
// Update hunk followed by another hunk (Add File).
|
| 370 |
+
assert_eq!(
|
| 371 |
+
parse_patch_text(
|
| 372 |
+
"*** Begin Patch\n\
|
| 373 |
+
*** Update File: file.py\n\
|
| 374 |
+
@@\n\
|
| 375 |
+
+line\n\
|
| 376 |
+
*** Add File: other.py\n\
|
| 377 |
+
+content\n\
|
| 378 |
+
*** End Patch",
|
| 379 |
+
ParseMode::Strict
|
| 380 |
+
)
|
| 381 |
+
.unwrap()
|
| 382 |
+
.hunks,
|
| 383 |
+
vec![
|
| 384 |
+
UpdateFile {
|
| 385 |
+
path: PathBuf::from("file.py"),
|
| 386 |
+
move_path: None,
|
| 387 |
+
chunks: vec![UpdateFileChunk {
|
| 388 |
+
change_context: None,
|
| 389 |
+
old_lines: vec![],
|
| 390 |
+
new_lines: vec!["line".to_string()],
|
| 391 |
+
context_line_indices: vec![],
|
| 392 |
+
is_end_of_file: false
|
| 393 |
+
}],
|
| 394 |
+
},
|
| 395 |
+
AddFile {
|
| 396 |
+
path: PathBuf::from("other.py"),
|
| 397 |
+
contents: "content\n".to_string()
|
| 398 |
+
}
|
| 399 |
+
]
|
| 400 |
+
);
|
| 401 |
+
|
| 402 |
+
// Update hunk without an explicit @@ header for the first chunk should parse.
|
| 403 |
+
// Use a raw string to preserve the leading space diff marker on the context line.
|
| 404 |
+
assert_eq!(
|
| 405 |
+
parse_patch_text(
|
| 406 |
+
r#"*** Begin Patch
|
| 407 |
+
*** Update File: file2.py
|
| 408 |
+
import foo
|
| 409 |
+
+bar
|
| 410 |
+
*** End Patch"#,
|
| 411 |
+
ParseMode::Strict
|
| 412 |
+
)
|
| 413 |
+
.unwrap()
|
| 414 |
+
.hunks,
|
| 415 |
+
vec![UpdateFile {
|
| 416 |
+
path: PathBuf::from("file2.py"),
|
| 417 |
+
move_path: None,
|
| 418 |
+
chunks: vec![UpdateFileChunk {
|
| 419 |
+
change_context: None,
|
| 420 |
+
old_lines: vec!["import foo".to_string()],
|
| 421 |
+
new_lines: vec!["import foo".to_string(), "bar".to_string()],
|
| 422 |
+
context_line_indices: vec![(0, 0)],
|
| 423 |
+
is_end_of_file: false,
|
| 424 |
+
}],
|
| 425 |
+
}]
|
| 426 |
+
);
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
#[test]
|
| 430 |
+
fn test_parse_patch_preserves_end_of_file_marker() {
|
| 431 |
+
let patch =
|
| 432 |
+
"*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch";
|
| 433 |
+
assert_eq!(
|
| 434 |
+
parse_patch(patch),
|
| 435 |
+
Ok(ApplyPatchArgs {
|
| 436 |
+
hunks: vec![UpdateFile {
|
| 437 |
+
path: PathBuf::from("file.txt"),
|
| 438 |
+
move_path: None,
|
| 439 |
+
chunks: vec![UpdateFileChunk {
|
| 440 |
+
change_context: None,
|
| 441 |
+
old_lines: Vec::new(),
|
| 442 |
+
new_lines: vec!["quux".to_string()],
|
| 443 |
+
context_line_indices: vec![],
|
| 444 |
+
is_end_of_file: true,
|
| 445 |
+
}],
|
| 446 |
+
}],
|
| 447 |
+
patch: patch.to_string(),
|
| 448 |
+
workdir: None,
|
| 449 |
+
environment_id: None,
|
| 450 |
+
})
|
| 451 |
+
);
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
#[test]
|
| 455 |
+
fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() {
|
| 456 |
+
let dir = tempfile::tempdir().unwrap();
|
| 457 |
+
let absolute_delete = dir.path().join("absolute-delete.py").abs();
|
| 458 |
+
let absolute_update = dir.path().join("absolute-update.py").abs();
|
| 459 |
+
let patch_text = format!(
|
| 460 |
+
r#"*** Begin Patch
|
| 461 |
+
*** Add File: relative-add.py
|
| 462 |
+
+content
|
| 463 |
+
*** Delete File: {}
|
| 464 |
+
*** Update File: {}
|
| 465 |
+
@@
|
| 466 |
+
-old
|
| 467 |
+
+new
|
| 468 |
+
*** End Patch"#,
|
| 469 |
+
absolute_delete.display(),
|
| 470 |
+
absolute_update.display()
|
| 471 |
+
);
|
| 472 |
+
|
| 473 |
+
assert_eq!(
|
| 474 |
+
parse_patch_text(&patch_text, ParseMode::Strict)
|
| 475 |
+
.unwrap()
|
| 476 |
+
.hunks,
|
| 477 |
+
vec![
|
| 478 |
+
AddFile {
|
| 479 |
+
path: PathBuf::from("relative-add.py"),
|
| 480 |
+
contents: "content\n".to_string()
|
| 481 |
+
},
|
| 482 |
+
DeleteFile {
|
| 483 |
+
path: absolute_delete.to_path_buf()
|
| 484 |
+
},
|
| 485 |
+
UpdateFile {
|
| 486 |
+
path: absolute_update.to_path_buf(),
|
| 487 |
+
move_path: None,
|
| 488 |
+
chunks: vec![UpdateFileChunk {
|
| 489 |
+
change_context: None,
|
| 490 |
+
old_lines: vec!["old".to_string()],
|
| 491 |
+
new_lines: vec!["new".to_string()],
|
| 492 |
+
context_line_indices: vec![],
|
| 493 |
+
is_end_of_file: false
|
| 494 |
+
}]
|
| 495 |
+
},
|
| 496 |
+
]
|
| 497 |
+
);
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
#[test]
|
| 501 |
+
fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() {
|
| 502 |
+
let cwd_dir = tempfile::tempdir().unwrap();
|
| 503 |
+
let cwd = PathUri::from_host_native_path(cwd_dir.path()).unwrap();
|
| 504 |
+
let absolute_dir = tempfile::tempdir().unwrap();
|
| 505 |
+
let absolute_add = absolute_dir.path().join("absolute-add.py").abs();
|
| 506 |
+
let absolute_delete = absolute_dir.path().join("absolute-delete.py").abs();
|
| 507 |
+
let absolute_update = absolute_dir.path().join("absolute-update.py").abs();
|
| 508 |
+
|
| 509 |
+
for (hunk, expected_path) in [
|
| 510 |
+
(
|
| 511 |
+
AddFile {
|
| 512 |
+
path: PathBuf::from("relative-add.py"),
|
| 513 |
+
contents: String::new(),
|
| 514 |
+
},
|
| 515 |
+
cwd.join("relative-add.py").unwrap(),
|
| 516 |
+
),
|
| 517 |
+
(
|
| 518 |
+
DeleteFile {
|
| 519 |
+
path: PathBuf::from("relative-delete.py"),
|
| 520 |
+
},
|
| 521 |
+
cwd.join("relative-delete.py").unwrap(),
|
| 522 |
+
),
|
| 523 |
+
(
|
| 524 |
+
UpdateFile {
|
| 525 |
+
path: PathBuf::from("relative-update.py"),
|
| 526 |
+
move_path: None,
|
| 527 |
+
chunks: Vec::new(),
|
| 528 |
+
},
|
| 529 |
+
cwd.join("relative-update.py").unwrap(),
|
| 530 |
+
),
|
| 531 |
+
(
|
| 532 |
+
AddFile {
|
| 533 |
+
path: absolute_add.to_path_buf(),
|
| 534 |
+
contents: String::new(),
|
| 535 |
+
},
|
| 536 |
+
PathUri::from_abs_path(&absolute_add),
|
| 537 |
+
),
|
| 538 |
+
(
|
| 539 |
+
DeleteFile {
|
| 540 |
+
path: absolute_delete.to_path_buf(),
|
| 541 |
+
},
|
| 542 |
+
PathUri::from_abs_path(&absolute_delete),
|
| 543 |
+
),
|
| 544 |
+
(
|
| 545 |
+
UpdateFile {
|
| 546 |
+
path: absolute_update.to_path_buf(),
|
| 547 |
+
move_path: None,
|
| 548 |
+
chunks: Vec::new(),
|
| 549 |
+
},
|
| 550 |
+
PathUri::from_abs_path(&absolute_update),
|
| 551 |
+
),
|
| 552 |
+
] {
|
| 553 |
+
assert_eq!(hunk.resolve_path(&cwd), Ok(expected_path));
|
| 554 |
+
}
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
#[test]
|
| 558 |
+
fn test_parse_patch_lenient() {
|
| 559 |
+
let patch_text = r#"*** Begin Patch
|
| 560 |
+
*** Update File: file2.py
|
| 561 |
+
import foo
|
| 562 |
+
+bar
|
| 563 |
+
*** End Patch"#;
|
| 564 |
+
let expected_patch = vec![UpdateFile {
|
| 565 |
+
path: PathBuf::from("file2.py"),
|
| 566 |
+
move_path: None,
|
| 567 |
+
chunks: vec![UpdateFileChunk {
|
| 568 |
+
change_context: None,
|
| 569 |
+
old_lines: vec!["import foo".to_string()],
|
| 570 |
+
new_lines: vec!["import foo".to_string(), "bar".to_string()],
|
| 571 |
+
context_line_indices: vec![(0, 0)],
|
| 572 |
+
is_end_of_file: false,
|
| 573 |
+
}],
|
| 574 |
+
}];
|
| 575 |
+
let expected_error =
|
| 576 |
+
InvalidPatchError("The first line of the patch must be '*** Begin Patch'".to_string());
|
| 577 |
+
|
| 578 |
+
let patch_text_in_heredoc = format!("<<EOF\n{patch_text}\nEOF\n");
|
| 579 |
+
assert_eq!(
|
| 580 |
+
parse_patch_text(&patch_text_in_heredoc, ParseMode::Strict),
|
| 581 |
+
Err(expected_error.clone())
|
| 582 |
+
);
|
| 583 |
+
assert_eq!(
|
| 584 |
+
parse_patch_text(&patch_text_in_heredoc, ParseMode::Lenient),
|
| 585 |
+
Ok(ApplyPatchArgs {
|
| 586 |
+
hunks: expected_patch.clone(),
|
| 587 |
+
patch: patch_text.to_string(),
|
| 588 |
+
workdir: None,
|
| 589 |
+
environment_id: None,
|
| 590 |
+
})
|
| 591 |
+
);
|
| 592 |
+
|
| 593 |
+
let patch_text_in_single_quoted_heredoc = format!("<<'EOF'\n{patch_text}\nEOF\n");
|
| 594 |
+
assert_eq!(
|
| 595 |
+
parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Strict),
|
| 596 |
+
Err(expected_error.clone())
|
| 597 |
+
);
|
| 598 |
+
assert_eq!(
|
| 599 |
+
parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Lenient),
|
| 600 |
+
Ok(ApplyPatchArgs {
|
| 601 |
+
hunks: expected_patch.clone(),
|
| 602 |
+
patch: patch_text.to_string(),
|
| 603 |
+
workdir: None,
|
| 604 |
+
environment_id: None,
|
| 605 |
+
})
|
| 606 |
+
);
|
| 607 |
+
|
| 608 |
+
let patch_text_in_double_quoted_heredoc = format!("<<\"EOF\"\n{patch_text}\nEOF\n");
|
| 609 |
+
assert_eq!(
|
| 610 |
+
parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Strict),
|
| 611 |
+
Err(expected_error.clone())
|
| 612 |
+
);
|
| 613 |
+
assert_eq!(
|
| 614 |
+
parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Lenient),
|
| 615 |
+
Ok(ApplyPatchArgs {
|
| 616 |
+
hunks: expected_patch,
|
| 617 |
+
patch: patch_text.to_string(),
|
| 618 |
+
workdir: None,
|
| 619 |
+
environment_id: None,
|
| 620 |
+
})
|
| 621 |
+
);
|
| 622 |
+
|
| 623 |
+
let patch_text_in_mismatched_quotes_heredoc = format!("<<\"EOF'\n{patch_text}\nEOF\n");
|
| 624 |
+
assert_eq!(
|
| 625 |
+
parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Strict),
|
| 626 |
+
Err(expected_error.clone())
|
| 627 |
+
);
|
| 628 |
+
assert_eq!(
|
| 629 |
+
parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Lenient),
|
| 630 |
+
Err(expected_error.clone())
|
| 631 |
+
);
|
| 632 |
+
|
| 633 |
+
let patch_text_with_missing_closing_heredoc =
|
| 634 |
+
"<<EOF\n*** Begin Patch\n*** Update File: file2.py\nEOF\n".to_string();
|
| 635 |
+
assert_eq!(
|
| 636 |
+
parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Strict),
|
| 637 |
+
Err(expected_error)
|
| 638 |
+
);
|
| 639 |
+
assert_eq!(
|
| 640 |
+
parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Lenient),
|
| 641 |
+
Err(InvalidPatchError(
|
| 642 |
+
"The last line of the patch must be '*** End Patch'".to_string()
|
| 643 |
+
))
|
| 644 |
+
);
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
#[test]
|
| 648 |
+
fn test_parse_patch_environment_id_preamble() {
|
| 649 |
+
assert_eq!(
|
| 650 |
+
parse_patch_text(
|
| 651 |
+
"*** Begin Patch\n\
|
| 652 |
+
*** Environment ID: remote\n\
|
| 653 |
+
*** Add File: hello.txt\n\
|
| 654 |
+
+hello\n\
|
| 655 |
+
*** End Patch",
|
| 656 |
+
ParseMode::Strict
|
| 657 |
+
),
|
| 658 |
+
Ok(ApplyPatchArgs {
|
| 659 |
+
hunks: vec![AddFile {
|
| 660 |
+
path: PathBuf::from("hello.txt"),
|
| 661 |
+
contents: "hello\n".to_string(),
|
| 662 |
+
}],
|
| 663 |
+
patch: "*** Begin Patch\n*** Environment ID: remote\n*** Add File: hello.txt\n+hello\n*** End Patch".to_string(),
|
| 664 |
+
workdir: None,
|
| 665 |
+
environment_id: Some("remote".to_string()),
|
| 666 |
+
})
|
| 667 |
+
);
|
| 668 |
+
|
| 669 |
+
assert_eq!(
|
| 670 |
+
parse_patch_text(
|
| 671 |
+
"*** Begin Patch\n\
|
| 672 |
+
*** Environment ID: \n\
|
| 673 |
+
*** Add File: hello.txt\n\
|
| 674 |
+
+hello\n\
|
| 675 |
+
*** End Patch",
|
| 676 |
+
ParseMode::Strict
|
| 677 |
+
),
|
| 678 |
+
Err(InvalidPatchError(
|
| 679 |
+
"apply_patch environment_id cannot be empty".to_string()
|
| 680 |
+
))
|
| 681 |
+
);
|
| 682 |
+
}
|
codex-rs/apply-patch/src/seek_sequence.rs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// Attempt to find the sequence of `pattern` lines within `lines` beginning at or after `start`.
|
| 2 |
+
/// Returns the starting index of the match or `None` if not found. Matches are attempted with
|
| 3 |
+
/// decreasing strictness: exact match, then ignoring trailing whitespace, then ignoring leading
|
| 4 |
+
/// and trailing whitespace. When `eof` is true, we first try starting at the end-of-file (so that
|
| 5 |
+
/// patterns intended to match file endings are applied at the end), and fall back to searching
|
| 6 |
+
/// from `start` if needed.
|
| 7 |
+
///
|
| 8 |
+
/// Special cases handled defensively:
|
| 9 |
+
/// • Empty `pattern` → returns `Some(start)` (no-op match)
|
| 10 |
+
/// • `pattern.len() > lines.len()` → returns `None` (cannot match, avoids
|
| 11 |
+
/// out‑of‑bounds panic that occurred pre‑2025‑04‑12)
|
| 12 |
+
pub(crate) fn seek_sequence(
|
| 13 |
+
lines: &[String],
|
| 14 |
+
pattern: &[String],
|
| 15 |
+
start: usize,
|
| 16 |
+
eof: bool,
|
| 17 |
+
update_file_mode: crate::ApplyPatchFileUpdateMode,
|
| 18 |
+
) -> Option<usize> {
|
| 19 |
+
if pattern.is_empty() {
|
| 20 |
+
return Some(start);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// When the pattern is longer than the available input there is no possible
|
| 24 |
+
// match. Early‑return to avoid the out‑of‑bounds slice that would occur in
|
| 25 |
+
// the search loops below (previously caused a panic when
|
| 26 |
+
// `pattern.len() > lines.len()`).
|
| 27 |
+
if pattern.len() > lines.len() {
|
| 28 |
+
return None;
|
| 29 |
+
}
|
| 30 |
+
let search_start = if eof && lines.len() >= pattern.len() {
|
| 31 |
+
let eof_start = lines.len() - pattern.len();
|
| 32 |
+
match update_file_mode {
|
| 33 |
+
crate::ApplyPatchFileUpdateMode::NormalizeToLf => eof_start,
|
| 34 |
+
crate::ApplyPatchFileUpdateMode::PreserveLineEndings => eof_start.max(start),
|
| 35 |
+
}
|
| 36 |
+
} else {
|
| 37 |
+
start
|
| 38 |
+
};
|
| 39 |
+
// Exact match first.
|
| 40 |
+
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
|
| 41 |
+
if lines[i..i + pattern.len()] == *pattern {
|
| 42 |
+
return Some(i);
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
// Then rstrip match.
|
| 46 |
+
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
|
| 47 |
+
let mut ok = true;
|
| 48 |
+
for (p_idx, pat) in pattern.iter().enumerate() {
|
| 49 |
+
if lines[i + p_idx].trim_end() != pat.trim_end() {
|
| 50 |
+
ok = false;
|
| 51 |
+
break;
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
if ok {
|
| 55 |
+
return Some(i);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
// Finally, trim both sides to allow more lenience.
|
| 59 |
+
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
|
| 60 |
+
let mut ok = true;
|
| 61 |
+
for (p_idx, pat) in pattern.iter().enumerate() {
|
| 62 |
+
if lines[i + p_idx].trim() != pat.trim() {
|
| 63 |
+
ok = false;
|
| 64 |
+
break;
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
if ok {
|
| 68 |
+
return Some(i);
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// ------------------------------------------------------------------
|
| 73 |
+
// Final, most permissive pass – attempt to match after *normalising*
|
| 74 |
+
// common Unicode punctuation to their ASCII equivalents so that diffs
|
| 75 |
+
// authored with plain ASCII characters can still be applied to source
|
| 76 |
+
// files that contain typographic dashes / quotes, etc. This mirrors the
|
| 77 |
+
// fuzzy behaviour of `git apply` which ignores minor byte-level
|
| 78 |
+
// differences when locating context lines.
|
| 79 |
+
// ------------------------------------------------------------------
|
| 80 |
+
|
| 81 |
+
fn normalise(s: &str) -> String {
|
| 82 |
+
s.trim()
|
| 83 |
+
.chars()
|
| 84 |
+
.map(|c| match c {
|
| 85 |
+
// Various dash / hyphen code-points → ASCII '-'
|
| 86 |
+
'\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
|
| 87 |
+
| '\u{2212}' => '-',
|
| 88 |
+
// Fancy single quotes → '\''
|
| 89 |
+
'\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
|
| 90 |
+
// Fancy double quotes → '"'
|
| 91 |
+
'\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
|
| 92 |
+
// Non-breaking space and other odd spaces → normal space
|
| 93 |
+
'\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
|
| 94 |
+
| '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
|
| 95 |
+
| '\u{3000}' => ' ',
|
| 96 |
+
other => other,
|
| 97 |
+
})
|
| 98 |
+
.collect::<String>()
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
|
| 102 |
+
let mut ok = true;
|
| 103 |
+
for (p_idx, pat) in pattern.iter().enumerate() {
|
| 104 |
+
if normalise(&lines[i + p_idx]) != normalise(pat) {
|
| 105 |
+
ok = false;
|
| 106 |
+
break;
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
if ok {
|
| 110 |
+
return Some(i);
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
None
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
#[cfg(test)]
|
| 118 |
+
mod tests {
|
| 119 |
+
use super::seek_sequence;
|
| 120 |
+
use crate::ApplyPatchFileUpdateMode;
|
| 121 |
+
use std::string::ToString;
|
| 122 |
+
|
| 123 |
+
fn to_vec(strings: &[&str]) -> Vec<String> {
|
| 124 |
+
strings.iter().map(ToString::to_string).collect()
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
#[test]
|
| 128 |
+
fn test_exact_match_finds_sequence() {
|
| 129 |
+
let lines = to_vec(&["foo", "bar", "baz"]);
|
| 130 |
+
let pattern = to_vec(&["bar", "baz"]);
|
| 131 |
+
assert_eq!(
|
| 132 |
+
seek_sequence(
|
| 133 |
+
&lines,
|
| 134 |
+
&pattern,
|
| 135 |
+
/*start*/ 0,
|
| 136 |
+
/*eof*/ false,
|
| 137 |
+
ApplyPatchFileUpdateMode::NormalizeToLf,
|
| 138 |
+
),
|
| 139 |
+
Some(1)
|
| 140 |
+
);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
#[test]
|
| 144 |
+
fn test_rstrip_match_ignores_trailing_whitespace() {
|
| 145 |
+
let lines = to_vec(&["foo ", "bar\t\t"]);
|
| 146 |
+
// Pattern omits trailing whitespace.
|
| 147 |
+
let pattern = to_vec(&["foo", "bar"]);
|
| 148 |
+
assert_eq!(
|
| 149 |
+
seek_sequence(
|
| 150 |
+
&lines,
|
| 151 |
+
&pattern,
|
| 152 |
+
/*start*/ 0,
|
| 153 |
+
/*eof*/ false,
|
| 154 |
+
ApplyPatchFileUpdateMode::NormalizeToLf,
|
| 155 |
+
),
|
| 156 |
+
Some(0)
|
| 157 |
+
);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
#[test]
|
| 161 |
+
fn test_trim_match_ignores_leading_and_trailing_whitespace() {
|
| 162 |
+
let lines = to_vec(&[" foo ", " bar\t"]);
|
| 163 |
+
// Pattern omits any additional whitespace.
|
| 164 |
+
let pattern = to_vec(&["foo", "bar"]);
|
| 165 |
+
assert_eq!(
|
| 166 |
+
seek_sequence(
|
| 167 |
+
&lines,
|
| 168 |
+
&pattern,
|
| 169 |
+
/*start*/ 0,
|
| 170 |
+
/*eof*/ false,
|
| 171 |
+
ApplyPatchFileUpdateMode::NormalizeToLf,
|
| 172 |
+
),
|
| 173 |
+
Some(0)
|
| 174 |
+
);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
#[test]
|
| 178 |
+
fn test_pattern_longer_than_input_returns_none() {
|
| 179 |
+
let lines = to_vec(&["just one line"]);
|
| 180 |
+
let pattern = to_vec(&["too", "many", "lines"]);
|
| 181 |
+
// Should not panic – must return None when pattern cannot possibly fit.
|
| 182 |
+
assert_eq!(
|
| 183 |
+
seek_sequence(
|
| 184 |
+
&lines,
|
| 185 |
+
&pattern,
|
| 186 |
+
/*start*/ 0,
|
| 187 |
+
/*eof*/ false,
|
| 188 |
+
ApplyPatchFileUpdateMode::NormalizeToLf,
|
| 189 |
+
),
|
| 190 |
+
None
|
| 191 |
+
);
|
| 192 |
+
}
|
| 193 |
+
}
|
codex-rs/apply-patch/src/standalone_executable.rs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::io::Read;
|
| 2 |
+
use std::io::Write;
|
| 3 |
+
|
| 4 |
+
pub fn main() -> ! {
|
| 5 |
+
let exit_code = run_main();
|
| 6 |
+
std::process::exit(exit_code);
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
/// We would prefer to return `std::process::ExitCode`, but its `exit_process()`
|
| 10 |
+
/// method is still a nightly API and we want main() to return !.
|
| 11 |
+
pub fn run_main() -> i32 {
|
| 12 |
+
// Expect either one argument (the full apply_patch payload) or read it from stdin.
|
| 13 |
+
let mut args = std::env::args_os();
|
| 14 |
+
let _argv0 = args.next();
|
| 15 |
+
|
| 16 |
+
let patch_arg = match args.next() {
|
| 17 |
+
Some(arg) => match arg.into_string() {
|
| 18 |
+
Ok(s) => s,
|
| 19 |
+
Err(_) => {
|
| 20 |
+
eprintln!("Error: apply_patch requires a UTF-8 PATCH argument.");
|
| 21 |
+
return 1;
|
| 22 |
+
}
|
| 23 |
+
},
|
| 24 |
+
None => {
|
| 25 |
+
// No argument provided; attempt to read the patch from stdin.
|
| 26 |
+
let mut buf = String::new();
|
| 27 |
+
match std::io::stdin().read_to_string(&mut buf) {
|
| 28 |
+
Ok(_) => {
|
| 29 |
+
if buf.is_empty() {
|
| 30 |
+
eprintln!("Usage: apply_patch 'PATCH'\n echo 'PATCH' | apply_patch");
|
| 31 |
+
return 2;
|
| 32 |
+
}
|
| 33 |
+
buf
|
| 34 |
+
}
|
| 35 |
+
Err(err) => {
|
| 36 |
+
eprintln!("Error: Failed to read PATCH from stdin.\n{err}");
|
| 37 |
+
return 1;
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
// Refuse extra args to avoid ambiguity.
|
| 44 |
+
if args.next().is_some() {
|
| 45 |
+
eprintln!("Error: apply_patch accepts exactly one argument.");
|
| 46 |
+
return 2;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
let mut stdout = std::io::stdout();
|
| 50 |
+
let mut stderr = std::io::stderr();
|
| 51 |
+
let cwd = match codex_utils_absolute_path::AbsolutePathBuf::current_dir() {
|
| 52 |
+
Ok(cwd) => cwd,
|
| 53 |
+
Err(err) => {
|
| 54 |
+
eprintln!("Error: Failed to determine current directory.\n{err}");
|
| 55 |
+
return 1;
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
let runtime = match tokio::runtime::Builder::new_current_thread()
|
| 59 |
+
.enable_all()
|
| 60 |
+
.build()
|
| 61 |
+
{
|
| 62 |
+
Ok(runtime) => runtime,
|
| 63 |
+
Err(err) => {
|
| 64 |
+
eprintln!("Error: Failed to initialize runtime.\n{err}");
|
| 65 |
+
return 1;
|
| 66 |
+
}
|
| 67 |
+
};
|
| 68 |
+
// TODO(anp): Discover the standalone executable cwd as PathUri directly.
|
| 69 |
+
let cwd = codex_utils_path_uri::PathUri::from_abs_path(&cwd);
|
| 70 |
+
let update_file_mode = crate::apply_patch_file_update_mode_from_env();
|
| 71 |
+
match runtime.block_on(crate::apply_patch_with_options(
|
| 72 |
+
&patch_arg,
|
| 73 |
+
crate::ApplyPatchOptions {
|
| 74 |
+
update_file_mode,
|
| 75 |
+
..Default::default()
|
| 76 |
+
},
|
| 77 |
+
&cwd,
|
| 78 |
+
&mut stdout,
|
| 79 |
+
&mut stderr,
|
| 80 |
+
codex_exec_server::LOCAL_FS.as_ref(),
|
| 81 |
+
/*sandbox*/ None,
|
| 82 |
+
)) {
|
| 83 |
+
Ok(_) => {
|
| 84 |
+
// Flush to ensure output ordering when used in pipelines.
|
| 85 |
+
let _ = stdout.flush();
|
| 86 |
+
0
|
| 87 |
+
}
|
| 88 |
+
Err(_) => 1,
|
| 89 |
+
}
|
| 90 |
+
}
|
codex-rs/apply-patch/src/streaming_parser.rs
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::path::PathBuf;
|
| 2 |
+
|
| 3 |
+
use crate::parser::ADD_FILE_MARKER;
|
| 4 |
+
use crate::parser::BEGIN_PATCH_MARKER;
|
| 5 |
+
use crate::parser::CHANGE_CONTEXT_MARKER;
|
| 6 |
+
use crate::parser::DELETE_FILE_MARKER;
|
| 7 |
+
use crate::parser::EMPTY_CHANGE_CONTEXT_MARKER;
|
| 8 |
+
use crate::parser::END_PATCH_MARKER;
|
| 9 |
+
use crate::parser::EOF_MARKER;
|
| 10 |
+
use crate::parser::Hunk;
|
| 11 |
+
use crate::parser::MOVE_TO_MARKER;
|
| 12 |
+
use crate::parser::ParseError;
|
| 13 |
+
use crate::parser::UPDATE_FILE_MARKER;
|
| 14 |
+
use crate::parser::UpdateFileChunk;
|
| 15 |
+
|
| 16 |
+
use Hunk::*;
|
| 17 |
+
use ParseError::*;
|
| 18 |
+
|
| 19 |
+
const ENVIRONMENT_ID_MARKER: &str = "*** Environment ID:";
|
| 20 |
+
|
| 21 |
+
#[derive(Debug, Default, Clone)]
|
| 22 |
+
pub struct StreamingPatchParser {
|
| 23 |
+
line_buffer: String,
|
| 24 |
+
state: StreamingParserState,
|
| 25 |
+
line_number: usize,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
#[derive(Debug, Default, Clone)]
|
| 29 |
+
struct StreamingParserState {
|
| 30 |
+
mode: StreamingParserMode,
|
| 31 |
+
hunks: Vec<Hunk>,
|
| 32 |
+
environment_id: Option<String>,
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
#[derive(Debug, Default, Clone, Copy)]
|
| 36 |
+
enum StreamingParserMode {
|
| 37 |
+
#[default]
|
| 38 |
+
NotStarted,
|
| 39 |
+
StartedPatch,
|
| 40 |
+
AddFile,
|
| 41 |
+
DeleteFile,
|
| 42 |
+
UpdateFile {
|
| 43 |
+
hunk_line_number: usize,
|
| 44 |
+
},
|
| 45 |
+
EndedPatch,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
impl StreamingPatchParser {
|
| 49 |
+
pub fn environment_id(&self) -> Option<&str> {
|
| 50 |
+
self.state.environment_id.as_deref()
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
fn ensure_update_hunk_is_not_empty(&self, line: &str) -> Result<(), ParseError> {
|
| 54 |
+
if let Some(UpdateFile { path, chunks, .. }) = self.state.hunks.last() {
|
| 55 |
+
if chunks.is_empty()
|
| 56 |
+
&& let StreamingParserMode::UpdateFile { hunk_line_number } = self.state.mode
|
| 57 |
+
{
|
| 58 |
+
return Err(InvalidHunkError {
|
| 59 |
+
message: format!("Update file hunk for path '{}' is empty", path.display()),
|
| 60 |
+
line_number: hunk_line_number,
|
| 61 |
+
});
|
| 62 |
+
}
|
| 63 |
+
if chunks
|
| 64 |
+
.last()
|
| 65 |
+
.is_some_and(|chunk| chunk.old_lines.is_empty() && chunk.new_lines.is_empty())
|
| 66 |
+
{
|
| 67 |
+
if line == END_PATCH_MARKER {
|
| 68 |
+
return Err(InvalidHunkError {
|
| 69 |
+
message: "Update hunk does not contain any lines".to_string(),
|
| 70 |
+
line_number: self.line_number,
|
| 71 |
+
});
|
| 72 |
+
}
|
| 73 |
+
return Err(InvalidHunkError {
|
| 74 |
+
message: format!(
|
| 75 |
+
"Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
|
| 76 |
+
),
|
| 77 |
+
line_number: self.line_number,
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
Ok(())
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
fn handle_hunk_headers_and_end_patch(&mut self, trimmed: &str) -> Result<bool, ParseError> {
|
| 85 |
+
if matches!(self.state.mode, StreamingParserMode::StartedPatch)
|
| 86 |
+
&& let Some(environment_id) = trimmed.strip_prefix(ENVIRONMENT_ID_MARKER)
|
| 87 |
+
{
|
| 88 |
+
if self.state.environment_id.is_some() {
|
| 89 |
+
return Err(InvalidPatchError(
|
| 90 |
+
"apply_patch environment_id cannot be specified more than once".to_string(),
|
| 91 |
+
));
|
| 92 |
+
}
|
| 93 |
+
let environment_id = environment_id.trim();
|
| 94 |
+
if environment_id.is_empty() {
|
| 95 |
+
return Err(InvalidPatchError(
|
| 96 |
+
"apply_patch environment_id cannot be empty".to_string(),
|
| 97 |
+
));
|
| 98 |
+
}
|
| 99 |
+
self.state.environment_id = Some(environment_id.to_string());
|
| 100 |
+
return Ok(true);
|
| 101 |
+
}
|
| 102 |
+
if trimmed == END_PATCH_MARKER {
|
| 103 |
+
self.ensure_update_hunk_is_not_empty(trimmed)?;
|
| 104 |
+
self.state.mode = StreamingParserMode::EndedPatch;
|
| 105 |
+
return Ok(true);
|
| 106 |
+
}
|
| 107 |
+
if let Some(path) = trimmed.strip_prefix(ADD_FILE_MARKER) {
|
| 108 |
+
self.ensure_update_hunk_is_not_empty(trimmed)?;
|
| 109 |
+
self.state.hunks.push(AddFile {
|
| 110 |
+
path: PathBuf::from(path),
|
| 111 |
+
contents: String::new(),
|
| 112 |
+
});
|
| 113 |
+
self.state.mode = StreamingParserMode::AddFile;
|
| 114 |
+
return Ok(true);
|
| 115 |
+
}
|
| 116 |
+
if let Some(path) = trimmed.strip_prefix(DELETE_FILE_MARKER) {
|
| 117 |
+
self.ensure_update_hunk_is_not_empty(trimmed)?;
|
| 118 |
+
self.state.hunks.push(DeleteFile {
|
| 119 |
+
path: PathBuf::from(path),
|
| 120 |
+
});
|
| 121 |
+
self.state.mode = StreamingParserMode::DeleteFile;
|
| 122 |
+
return Ok(true);
|
| 123 |
+
}
|
| 124 |
+
if let Some(path) = trimmed.strip_prefix(UPDATE_FILE_MARKER) {
|
| 125 |
+
self.ensure_update_hunk_is_not_empty(trimmed)?;
|
| 126 |
+
self.state.hunks.push(UpdateFile {
|
| 127 |
+
path: PathBuf::from(path),
|
| 128 |
+
move_path: None,
|
| 129 |
+
chunks: Vec::new(),
|
| 130 |
+
});
|
| 131 |
+
self.state.mode = StreamingParserMode::UpdateFile {
|
| 132 |
+
hunk_line_number: self.line_number,
|
| 133 |
+
};
|
| 134 |
+
return Ok(true);
|
| 135 |
+
}
|
| 136 |
+
Ok(false)
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
pub fn push_delta(&mut self, delta: &str) -> Result<Vec<Hunk>, ParseError> {
|
| 140 |
+
for ch in delta.chars() {
|
| 141 |
+
if ch == '\n' {
|
| 142 |
+
let mut line = std::mem::take(&mut self.line_buffer);
|
| 143 |
+
line.truncate(line.strip_suffix('\r').map_or(line.len(), str::len));
|
| 144 |
+
self.line_number += 1;
|
| 145 |
+
self.process_line(&line)?;
|
| 146 |
+
} else {
|
| 147 |
+
self.line_buffer.push(ch);
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
Ok(self.state.hunks.clone())
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
pub fn finish(&mut self) -> Result<Vec<Hunk>, ParseError> {
|
| 155 |
+
if !self.line_buffer.is_empty() {
|
| 156 |
+
let line = std::mem::take(&mut self.line_buffer);
|
| 157 |
+
self.line_number += 1;
|
| 158 |
+
if line.trim() == END_PATCH_MARKER {
|
| 159 |
+
self.ensure_update_hunk_is_not_empty(line.trim())?;
|
| 160 |
+
self.state.mode = StreamingParserMode::EndedPatch;
|
| 161 |
+
} else {
|
| 162 |
+
self.process_line(&line)?;
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
if !matches!(self.state.mode, StreamingParserMode::EndedPatch) {
|
| 167 |
+
return Err(InvalidPatchError(
|
| 168 |
+
"The last line of the patch must be '*** End Patch'".to_string(),
|
| 169 |
+
));
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
Ok(self.state.hunks.clone())
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
fn process_line(&mut self, line: &str) -> Result<(), ParseError> {
|
| 176 |
+
let trimmed = line.trim();
|
| 177 |
+
match self.state.mode {
|
| 178 |
+
StreamingParserMode::NotStarted => {
|
| 179 |
+
if trimmed == BEGIN_PATCH_MARKER {
|
| 180 |
+
self.state.mode = StreamingParserMode::StartedPatch;
|
| 181 |
+
return Ok(());
|
| 182 |
+
}
|
| 183 |
+
Err(InvalidPatchError(
|
| 184 |
+
"The first line of the patch must be '*** Begin Patch'".to_string(),
|
| 185 |
+
))
|
| 186 |
+
}
|
| 187 |
+
StreamingParserMode::StartedPatch => {
|
| 188 |
+
if self.handle_hunk_headers_and_end_patch(trimmed)? {
|
| 189 |
+
return Ok(());
|
| 190 |
+
}
|
| 191 |
+
Err(InvalidHunkError {
|
| 192 |
+
message: format!(
|
| 193 |
+
"'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
|
| 194 |
+
),
|
| 195 |
+
line_number: self.line_number,
|
| 196 |
+
})
|
| 197 |
+
}
|
| 198 |
+
StreamingParserMode::AddFile => {
|
| 199 |
+
if self.handle_hunk_headers_and_end_patch(trimmed)? {
|
| 200 |
+
return Ok(());
|
| 201 |
+
}
|
| 202 |
+
if let Some(line_to_add) = line.strip_prefix('+')
|
| 203 |
+
&& let Some(AddFile { contents, .. }) = self.state.hunks.last_mut()
|
| 204 |
+
{
|
| 205 |
+
contents.push_str(line_to_add);
|
| 206 |
+
contents.push('\n');
|
| 207 |
+
return Ok(());
|
| 208 |
+
}
|
| 209 |
+
Err(InvalidHunkError {
|
| 210 |
+
message: format!(
|
| 211 |
+
"'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
|
| 212 |
+
),
|
| 213 |
+
line_number: self.line_number,
|
| 214 |
+
})
|
| 215 |
+
}
|
| 216 |
+
StreamingParserMode::DeleteFile => {
|
| 217 |
+
if self.handle_hunk_headers_and_end_patch(trimmed)? {
|
| 218 |
+
return Ok(());
|
| 219 |
+
}
|
| 220 |
+
Err(InvalidHunkError {
|
| 221 |
+
message: format!(
|
| 222 |
+
"'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
|
| 223 |
+
),
|
| 224 |
+
line_number: self.line_number,
|
| 225 |
+
})
|
| 226 |
+
}
|
| 227 |
+
StreamingParserMode::UpdateFile { hunk_line_number } => {
|
| 228 |
+
let update_line = line.trim_end();
|
| 229 |
+
if self.handle_hunk_headers_and_end_patch(update_line)? {
|
| 230 |
+
return Ok(());
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
if let Some(UpdateFile {
|
| 234 |
+
move_path, chunks, ..
|
| 235 |
+
}) = self.state.hunks.last_mut()
|
| 236 |
+
{
|
| 237 |
+
if chunks.last().is_some_and(|chunk| chunk.is_end_of_file) {
|
| 238 |
+
if update_line.is_empty() {
|
| 239 |
+
return Ok(());
|
| 240 |
+
}
|
| 241 |
+
if update_line != EMPTY_CHANGE_CONTEXT_MARKER
|
| 242 |
+
&& !update_line.starts_with(CHANGE_CONTEXT_MARKER)
|
| 243 |
+
{
|
| 244 |
+
return Err(InvalidHunkError {
|
| 245 |
+
message: format!(
|
| 246 |
+
"Expected update hunk to start with a @@ context marker, got: '{line}'"
|
| 247 |
+
),
|
| 248 |
+
line_number: self.line_number,
|
| 249 |
+
});
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
if chunks.is_empty()
|
| 254 |
+
&& move_path.is_none()
|
| 255 |
+
&& let Some(move_to_path) = update_line.strip_prefix(MOVE_TO_MARKER)
|
| 256 |
+
{
|
| 257 |
+
*move_path = Some(PathBuf::from(move_to_path));
|
| 258 |
+
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
| 259 |
+
return Ok(());
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
if (update_line == EMPTY_CHANGE_CONTEXT_MARKER
|
| 263 |
+
|| update_line.starts_with(CHANGE_CONTEXT_MARKER))
|
| 264 |
+
&& chunks.last().is_some_and(|chunk| {
|
| 265 |
+
chunk.old_lines.is_empty() && chunk.new_lines.is_empty()
|
| 266 |
+
})
|
| 267 |
+
{
|
| 268 |
+
return Err(InvalidHunkError {
|
| 269 |
+
message: format!(
|
| 270 |
+
"Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
|
| 271 |
+
),
|
| 272 |
+
line_number: self.line_number,
|
| 273 |
+
});
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
if update_line == EMPTY_CHANGE_CONTEXT_MARKER {
|
| 277 |
+
chunks.push(UpdateFileChunk::default());
|
| 278 |
+
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
| 279 |
+
return Ok(());
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
if let Some(change_context) = update_line.strip_prefix(CHANGE_CONTEXT_MARKER) {
|
| 283 |
+
chunks.push(UpdateFileChunk {
|
| 284 |
+
change_context: Some(change_context.to_string()),
|
| 285 |
+
..UpdateFileChunk::default()
|
| 286 |
+
});
|
| 287 |
+
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
| 288 |
+
return Ok(());
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
if update_line == EOF_MARKER {
|
| 292 |
+
if chunks.last().is_some_and(|chunk| {
|
| 293 |
+
chunk.old_lines.is_empty() && chunk.new_lines.is_empty()
|
| 294 |
+
}) {
|
| 295 |
+
return Err(InvalidHunkError {
|
| 296 |
+
message: "Update hunk does not contain any lines".to_string(),
|
| 297 |
+
line_number: self.line_number,
|
| 298 |
+
});
|
| 299 |
+
}
|
| 300 |
+
if let Some(chunk) = chunks.last_mut() {
|
| 301 |
+
chunk.is_end_of_file = true;
|
| 302 |
+
}
|
| 303 |
+
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
| 304 |
+
return Ok(());
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
if line.is_empty() {
|
| 308 |
+
if chunks.is_empty() {
|
| 309 |
+
chunks.push(UpdateFileChunk::default());
|
| 310 |
+
}
|
| 311 |
+
if let Some(chunk) = chunks.last_mut() {
|
| 312 |
+
chunk.push_context_line(String::new());
|
| 313 |
+
}
|
| 314 |
+
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
| 315 |
+
return Ok(());
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
if let Some(line_to_add) = line.strip_prefix(' ') {
|
| 319 |
+
if chunks.is_empty() {
|
| 320 |
+
chunks.push(UpdateFileChunk::default());
|
| 321 |
+
}
|
| 322 |
+
if let Some(chunk) = chunks.last_mut() {
|
| 323 |
+
chunk.push_context_line(line_to_add.to_string());
|
| 324 |
+
}
|
| 325 |
+
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
| 326 |
+
return Ok(());
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
if let Some(line_to_add) = line.strip_prefix('+') {
|
| 330 |
+
if chunks.is_empty() {
|
| 331 |
+
chunks.push(UpdateFileChunk::default());
|
| 332 |
+
}
|
| 333 |
+
if let Some(chunk) = chunks.last_mut() {
|
| 334 |
+
chunk.new_lines.push(line_to_add.to_string());
|
| 335 |
+
}
|
| 336 |
+
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
| 337 |
+
return Ok(());
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
if let Some(line_to_remove) = line.strip_prefix('-') {
|
| 341 |
+
if chunks.is_empty() {
|
| 342 |
+
chunks.push(UpdateFileChunk::default());
|
| 343 |
+
}
|
| 344 |
+
if let Some(chunk) = chunks.last_mut() {
|
| 345 |
+
chunk.old_lines.push(line_to_remove.to_string());
|
| 346 |
+
}
|
| 347 |
+
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
| 348 |
+
return Ok(());
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
if chunks.last().is_some_and(|chunk| {
|
| 352 |
+
!chunk.old_lines.is_empty() || !chunk.new_lines.is_empty()
|
| 353 |
+
}) {
|
| 354 |
+
return Err(InvalidHunkError {
|
| 355 |
+
message: format!(
|
| 356 |
+
"Expected update hunk to start with a @@ context marker, got: '{line}'"
|
| 357 |
+
),
|
| 358 |
+
line_number: self.line_number,
|
| 359 |
+
});
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
Err(InvalidHunkError {
|
| 363 |
+
message: format!(
|
| 364 |
+
"Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
|
| 365 |
+
),
|
| 366 |
+
line_number: self.line_number,
|
| 367 |
+
})
|
| 368 |
+
}
|
| 369 |
+
StreamingParserMode::EndedPatch => {
|
| 370 |
+
if trimmed.is_empty() {
|
| 371 |
+
Ok(())
|
| 372 |
+
} else {
|
| 373 |
+
Err(InvalidPatchError(
|
| 374 |
+
"The last line of the patch must be '*** End Patch'".to_string(),
|
| 375 |
+
))
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
#[cfg(test)]
|
| 383 |
+
mod tests {
|
| 384 |
+
use pretty_assertions::assert_eq;
|
| 385 |
+
use std::path::PathBuf;
|
| 386 |
+
|
| 387 |
+
use super::*;
|
| 388 |
+
|
| 389 |
+
#[test]
|
| 390 |
+
fn test_streaming_patch_parser_streams_complete_lines_before_end_patch() {
|
| 391 |
+
let mut parser = StreamingPatchParser::default();
|
| 392 |
+
assert_eq!(
|
| 393 |
+
parser.push_delta("*** Begin Patch\n*** Add File: src/hello.txt\n+hello\n+wor"),
|
| 394 |
+
Ok(vec![AddFile {
|
| 395 |
+
path: PathBuf::from("src/hello.txt"),
|
| 396 |
+
contents: "hello\n".to_string(),
|
| 397 |
+
}])
|
| 398 |
+
);
|
| 399 |
+
assert_eq!(
|
| 400 |
+
parser.push_delta("ld\n"),
|
| 401 |
+
Ok(vec![AddFile {
|
| 402 |
+
path: PathBuf::from("src/hello.txt"),
|
| 403 |
+
contents: "hello\nworld\n".to_string(),
|
| 404 |
+
}])
|
| 405 |
+
);
|
| 406 |
+
|
| 407 |
+
let mut parser = StreamingPatchParser::default();
|
| 408 |
+
assert_eq!(
|
| 409 |
+
parser.push_delta(
|
| 410 |
+
"*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-old\n+new\n",
|
| 411 |
+
),
|
| 412 |
+
Ok(vec![UpdateFile {
|
| 413 |
+
path: PathBuf::from("src/old.rs"),
|
| 414 |
+
move_path: Some(PathBuf::from("src/new.rs")),
|
| 415 |
+
chunks: vec![UpdateFileChunk {
|
| 416 |
+
change_context: None,
|
| 417 |
+
old_lines: vec!["old".to_string()],
|
| 418 |
+
new_lines: vec!["new".to_string()],
|
| 419 |
+
context_line_indices: vec![],
|
| 420 |
+
is_end_of_file: false,
|
| 421 |
+
}],
|
| 422 |
+
}])
|
| 423 |
+
);
|
| 424 |
+
|
| 425 |
+
let mut parser = StreamingPatchParser::default();
|
| 426 |
+
assert_eq!(
|
| 427 |
+
parser.push_delta("*** Begin Patch\n*** Delete File: gone.txt"),
|
| 428 |
+
Ok(Vec::new())
|
| 429 |
+
);
|
| 430 |
+
assert_eq!(
|
| 431 |
+
parser.push_delta("\n"),
|
| 432 |
+
Ok(vec![DeleteFile {
|
| 433 |
+
path: PathBuf::from("gone.txt"),
|
| 434 |
+
}])
|
| 435 |
+
);
|
| 436 |
+
|
| 437 |
+
let mut parser = StreamingPatchParser::default();
|
| 438 |
+
assert_eq!(
|
| 439 |
+
parser.push_delta(
|
| 440 |
+
"*** Begin Patch\n*** Add File: src/one.txt\n+one\n*** Delete File: src/two.txt\n",
|
| 441 |
+
),
|
| 442 |
+
Ok(vec![
|
| 443 |
+
AddFile {
|
| 444 |
+
path: PathBuf::from("src/one.txt"),
|
| 445 |
+
contents: "one\n".to_string(),
|
| 446 |
+
},
|
| 447 |
+
DeleteFile {
|
| 448 |
+
path: PathBuf::from("src/two.txt"),
|
| 449 |
+
},
|
| 450 |
+
])
|
| 451 |
+
);
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
#[test]
|
| 455 |
+
fn test_streaming_patch_parser_environment_id_mode() {
|
| 456 |
+
let patch = "\
|
| 457 |
+
*** Begin Patch
|
| 458 |
+
*** Environment ID: remote
|
| 459 |
+
*** Add File: src/hello.txt
|
| 460 |
+
+hello
|
| 461 |
+
*** End Patch
|
| 462 |
+
";
|
| 463 |
+
|
| 464 |
+
let mut parser = StreamingPatchParser::default();
|
| 465 |
+
assert_eq!(
|
| 466 |
+
parser.push_delta(patch),
|
| 467 |
+
Ok(vec![AddFile {
|
| 468 |
+
path: PathBuf::from("src/hello.txt"),
|
| 469 |
+
contents: "hello\n".to_string(),
|
| 470 |
+
}])
|
| 471 |
+
);
|
| 472 |
+
assert_eq!(parser.environment_id(), Some("remote"));
|
| 473 |
+
|
| 474 |
+
let mut parser = StreamingPatchParser::default();
|
| 475 |
+
assert_eq!(
|
| 476 |
+
parser.push_delta(
|
| 477 |
+
"*** Begin Patch\n*** Environment ID: first\n*** Environment ID: second\n",
|
| 478 |
+
),
|
| 479 |
+
Err(InvalidPatchError(
|
| 480 |
+
"apply_patch environment_id cannot be specified more than once".to_string(),
|
| 481 |
+
))
|
| 482 |
+
);
|
| 483 |
+
|
| 484 |
+
let mut parser = StreamingPatchParser::default();
|
| 485 |
+
assert_eq!(
|
| 486 |
+
parser.push_delta("*** Begin Patch\n*** Environment ID: \n"),
|
| 487 |
+
Err(InvalidPatchError(
|
| 488 |
+
"apply_patch environment_id cannot be empty".to_string(),
|
| 489 |
+
))
|
| 490 |
+
);
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
#[test]
|
| 494 |
+
fn test_streaming_patch_parser_large_patch_split_by_character() {
|
| 495 |
+
let patch = "\
|
| 496 |
+
*** Begin Patch
|
| 497 |
+
*** Add File: docs/release-notes.md
|
| 498 |
+
+# Release notes
|
| 499 |
+
+
|
| 500 |
+
+## CLI
|
| 501 |
+
+- Surface apply_patch progress while arguments stream.
|
| 502 |
+
+- Keep final patch application gated on the completed tool call.
|
| 503 |
+
+- Include file summaries in the progress event payload.
|
| 504 |
+
*** Update File: src/config.rs
|
| 505 |
+
@@ impl Config
|
| 506 |
+
- pub apply_patch_progress: bool,
|
| 507 |
+
+ pub stream_apply_patch_progress: bool,
|
| 508 |
+
pub include_diagnostics: bool,
|
| 509 |
+
@@ fn default_progress_interval()
|
| 510 |
+
- Duration::from_millis(500)
|
| 511 |
+
+ Duration::from_millis(250)
|
| 512 |
+
*** Delete File: src/legacy_patch_progress.rs
|
| 513 |
+
*** Update File: crates/cli/src/main.rs
|
| 514 |
+
*** Move to: crates/cli/src/bin/codex.rs
|
| 515 |
+
@@ fn run()
|
| 516 |
+
- let args = Args::parse();
|
| 517 |
+
- dispatch(args)
|
| 518 |
+
+ let cli = Cli::parse();
|
| 519 |
+
+ dispatch(cli)
|
| 520 |
+
*** Add File: tests/fixtures/apply_patch_progress.json
|
| 521 |
+
+{
|
| 522 |
+
+ \"type\": \"apply_patch_progress\",
|
| 523 |
+
+ \"hunks\": [
|
| 524 |
+
+ { \"operation\": \"add\", \"path\": \"docs/release-notes.md\" },
|
| 525 |
+
+ { \"operation\": \"update\", \"path\": \"src/config.rs\" }
|
| 526 |
+
+ ]
|
| 527 |
+
+}
|
| 528 |
+
*** Update File: README.md
|
| 529 |
+
@@ Development workflow
|
| 530 |
+
Build the Rust workspace before opening a pull request.
|
| 531 |
+
+When touching streamed tool calls, include parser coverage for partial input.
|
| 532 |
+
+Prefer tests that exercise the exact event payload shape.
|
| 533 |
+
*** Delete File: docs/old-apply-patch-progress.md
|
| 534 |
+
*** End Patch";
|
| 535 |
+
|
| 536 |
+
let mut parser = StreamingPatchParser::default();
|
| 537 |
+
let mut max_hunk_count = 0;
|
| 538 |
+
let mut saw_hunk_counts = Vec::new();
|
| 539 |
+
let mut hunks = Vec::new();
|
| 540 |
+
for ch in patch.chars() {
|
| 541 |
+
let updated_hunks = parser.push_delta(&ch.to_string()).unwrap();
|
| 542 |
+
if !updated_hunks.is_empty() {
|
| 543 |
+
let hunk_count = updated_hunks.len();
|
| 544 |
+
assert!(
|
| 545 |
+
hunk_count >= max_hunk_count,
|
| 546 |
+
"hunk count should never decrease while streaming: {hunk_count} < {max_hunk_count}",
|
| 547 |
+
);
|
| 548 |
+
if hunk_count > max_hunk_count {
|
| 549 |
+
saw_hunk_counts.push(hunk_count);
|
| 550 |
+
max_hunk_count = hunk_count;
|
| 551 |
+
}
|
| 552 |
+
hunks = updated_hunks;
|
| 553 |
+
}
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
assert_eq!(saw_hunk_counts, vec![1, 2, 3, 4, 5, 6, 7]);
|
| 557 |
+
assert_eq!(hunks.len(), 7);
|
| 558 |
+
assert_eq!(
|
| 559 |
+
hunks
|
| 560 |
+
.iter()
|
| 561 |
+
.map(|hunk| match hunk {
|
| 562 |
+
AddFile { .. } => "add",
|
| 563 |
+
DeleteFile { .. } => "delete",
|
| 564 |
+
UpdateFile {
|
| 565 |
+
move_path: Some(_), ..
|
| 566 |
+
} => "move-update",
|
| 567 |
+
UpdateFile {
|
| 568 |
+
move_path: None, ..
|
| 569 |
+
} => "update",
|
| 570 |
+
})
|
| 571 |
+
.collect::<Vec<_>>(),
|
| 572 |
+
vec![
|
| 573 |
+
"add",
|
| 574 |
+
"update",
|
| 575 |
+
"delete",
|
| 576 |
+
"move-update",
|
| 577 |
+
"add",
|
| 578 |
+
"update",
|
| 579 |
+
"delete"
|
| 580 |
+
]
|
| 581 |
+
);
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
#[test]
|
| 585 |
+
fn test_streaming_patch_parser_keeps_indented_update_markers_as_context_lines() {
|
| 586 |
+
let mut parser = StreamingPatchParser::default();
|
| 587 |
+
assert_eq!(
|
| 588 |
+
parser.push_delta(
|
| 589 |
+
"\
|
| 590 |
+
*** Begin Patch
|
| 591 |
+
*** Update File: a.txt
|
| 592 |
+
@@
|
| 593 |
+
-old a
|
| 594 |
+
+new a
|
| 595 |
+
*** Update File: b.txt
|
| 596 |
+
@@
|
| 597 |
+
-old b
|
| 598 |
+
+new b
|
| 599 |
+
*** End Patch
|
| 600 |
+
",
|
| 601 |
+
),
|
| 602 |
+
Ok(vec![UpdateFile {
|
| 603 |
+
path: PathBuf::from("a.txt"),
|
| 604 |
+
move_path: None,
|
| 605 |
+
chunks: vec![
|
| 606 |
+
UpdateFileChunk {
|
| 607 |
+
change_context: None,
|
| 608 |
+
old_lines: vec!["old a".to_string(), "*** Update File: b.txt".to_string()],
|
| 609 |
+
new_lines: vec!["new a".to_string(), "*** Update File: b.txt".to_string()],
|
| 610 |
+
context_line_indices: vec![(1, 1)],
|
| 611 |
+
is_end_of_file: false,
|
| 612 |
+
},
|
| 613 |
+
UpdateFileChunk {
|
| 614 |
+
change_context: None,
|
| 615 |
+
old_lines: vec!["old b".to_string()],
|
| 616 |
+
new_lines: vec!["new b".to_string()],
|
| 617 |
+
context_line_indices: vec![],
|
| 618 |
+
is_end_of_file: false,
|
| 619 |
+
},
|
| 620 |
+
],
|
| 621 |
+
}])
|
| 622 |
+
);
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
#[test]
|
| 626 |
+
fn test_streaming_patch_parser_preserves_bare_empty_update_lines() {
|
| 627 |
+
let mut parser = StreamingPatchParser::default();
|
| 628 |
+
assert_eq!(
|
| 629 |
+
parser.push_delta(
|
| 630 |
+
"\
|
| 631 |
+
*** Begin Patch
|
| 632 |
+
*** Update File: file.txt
|
| 633 |
+
@@
|
| 634 |
+
context before
|
| 635 |
+
|
| 636 |
+
context after
|
| 637 |
+
*** End Patch
|
| 638 |
+
",
|
| 639 |
+
),
|
| 640 |
+
Ok(vec![UpdateFile {
|
| 641 |
+
path: PathBuf::from("file.txt"),
|
| 642 |
+
move_path: None,
|
| 643 |
+
chunks: vec![UpdateFileChunk {
|
| 644 |
+
change_context: None,
|
| 645 |
+
// The normal parser treats a bare empty line in an update hunk as an
|
| 646 |
+
// empty context line. Preserve that leniency in the streaming parser.
|
| 647 |
+
old_lines: vec![
|
| 648 |
+
"context before".to_string(),
|
| 649 |
+
String::new(),
|
| 650 |
+
"context after".to_string(),
|
| 651 |
+
],
|
| 652 |
+
new_lines: vec![
|
| 653 |
+
"context before".to_string(),
|
| 654 |
+
String::new(),
|
| 655 |
+
"context after".to_string(),
|
| 656 |
+
],
|
| 657 |
+
context_line_indices: vec![(0, 0), (1, 1), (2, 2)],
|
| 658 |
+
is_end_of_file: false,
|
| 659 |
+
}],
|
| 660 |
+
}])
|
| 661 |
+
);
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
#[test]
|
| 665 |
+
fn test_streaming_patch_parser_ignores_empty_lines_after_end_of_file() {
|
| 666 |
+
let mut parser = StreamingPatchParser::default();
|
| 667 |
+
assert_eq!(
|
| 668 |
+
parser.push_delta(
|
| 669 |
+
"*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch\n",
|
| 670 |
+
),
|
| 671 |
+
Ok(vec![UpdateFile {
|
| 672 |
+
path: PathBuf::from("file.txt"),
|
| 673 |
+
move_path: None,
|
| 674 |
+
chunks: vec![UpdateFileChunk {
|
| 675 |
+
change_context: None,
|
| 676 |
+
old_lines: Vec::new(),
|
| 677 |
+
new_lines: vec!["quux".to_string()],
|
| 678 |
+
context_line_indices: vec![],
|
| 679 |
+
is_end_of_file: true,
|
| 680 |
+
}],
|
| 681 |
+
}])
|
| 682 |
+
);
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
#[test]
|
| 686 |
+
fn test_streaming_patch_parser_matches_line_ending_behavior() {
|
| 687 |
+
let mut parser = StreamingPatchParser::default();
|
| 688 |
+
assert_eq!(
|
| 689 |
+
parser.push_delta("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\n+new\r\n*** End Patch\r\n"),
|
| 690 |
+
Ok(vec![UpdateFile {
|
| 691 |
+
path: PathBuf::from("file.txt"),
|
| 692 |
+
move_path: None,
|
| 693 |
+
chunks: vec![UpdateFileChunk {
|
| 694 |
+
change_context: None,
|
| 695 |
+
old_lines: vec!["old".to_string()],
|
| 696 |
+
new_lines: vec!["new".to_string()],
|
| 697 |
+
context_line_indices: vec![],
|
| 698 |
+
is_end_of_file: false,
|
| 699 |
+
}],
|
| 700 |
+
}])
|
| 701 |
+
);
|
| 702 |
+
|
| 703 |
+
let mut parser = StreamingPatchParser::default();
|
| 704 |
+
assert_eq!(
|
| 705 |
+
parser.push_delta("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\r\n+new\r\n*** End Patch\r\n"),
|
| 706 |
+
Ok(vec![UpdateFile {
|
| 707 |
+
path: PathBuf::from("file.txt"),
|
| 708 |
+
move_path: None,
|
| 709 |
+
chunks: vec![UpdateFileChunk {
|
| 710 |
+
change_context: None,
|
| 711 |
+
old_lines: vec!["old\r".to_string()],
|
| 712 |
+
new_lines: vec!["new".to_string()],
|
| 713 |
+
context_line_indices: vec![],
|
| 714 |
+
is_end_of_file: false,
|
| 715 |
+
}],
|
| 716 |
+
}])
|
| 717 |
+
);
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
#[test]
|
| 721 |
+
fn test_streaming_patch_parser_finish_processes_final_line_without_newline() {
|
| 722 |
+
let mut parser = StreamingPatchParser::default();
|
| 723 |
+
assert_eq!(
|
| 724 |
+
parser.push_delta("*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch"),
|
| 725 |
+
Ok(vec![AddFile {
|
| 726 |
+
path: PathBuf::from("file.txt"),
|
| 727 |
+
contents: "hello\n".to_string(),
|
| 728 |
+
}])
|
| 729 |
+
);
|
| 730 |
+
assert_eq!(
|
| 731 |
+
parser.finish(),
|
| 732 |
+
Ok(vec![AddFile {
|
| 733 |
+
path: PathBuf::from("file.txt"),
|
| 734 |
+
contents: "hello\n".to_string(),
|
| 735 |
+
}])
|
| 736 |
+
);
|
| 737 |
+
|
| 738 |
+
let mut parser = StreamingPatchParser::default();
|
| 739 |
+
assert_eq!(
|
| 740 |
+
parser.push_delta(
|
| 741 |
+
"*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n+new\n *** End Patch",
|
| 742 |
+
),
|
| 743 |
+
Ok(vec![UpdateFile {
|
| 744 |
+
path: PathBuf::from("file.txt"),
|
| 745 |
+
move_path: None,
|
| 746 |
+
chunks: vec![UpdateFileChunk {
|
| 747 |
+
change_context: None,
|
| 748 |
+
old_lines: vec!["old".to_string()],
|
| 749 |
+
new_lines: vec!["new".to_string()],
|
| 750 |
+
context_line_indices: vec![],
|
| 751 |
+
is_end_of_file: false,
|
| 752 |
+
}],
|
| 753 |
+
}])
|
| 754 |
+
);
|
| 755 |
+
assert_eq!(
|
| 756 |
+
parser.finish(),
|
| 757 |
+
Ok(vec![UpdateFile {
|
| 758 |
+
path: PathBuf::from("file.txt"),
|
| 759 |
+
move_path: None,
|
| 760 |
+
chunks: vec![UpdateFileChunk {
|
| 761 |
+
change_context: None,
|
| 762 |
+
old_lines: vec!["old".to_string()],
|
| 763 |
+
new_lines: vec!["new".to_string()],
|
| 764 |
+
context_line_indices: vec![],
|
| 765 |
+
is_end_of_file: false,
|
| 766 |
+
}],
|
| 767 |
+
}])
|
| 768 |
+
);
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
#[test]
|
| 772 |
+
fn test_streaming_patch_parser_finish_requires_end_patch() {
|
| 773 |
+
let mut parser = StreamingPatchParser::default();
|
| 774 |
+
assert_eq!(
|
| 775 |
+
parser.push_delta("*** Begin Patch\n*** Add File: file.txt\n+hello\n"),
|
| 776 |
+
Ok(vec![AddFile {
|
| 777 |
+
path: PathBuf::from("file.txt"),
|
| 778 |
+
contents: "hello\n".to_string(),
|
| 779 |
+
}])
|
| 780 |
+
);
|
| 781 |
+
assert_eq!(
|
| 782 |
+
parser.finish(),
|
| 783 |
+
Err(InvalidPatchError(
|
| 784 |
+
"The last line of the patch must be '*** End Patch'".to_string(),
|
| 785 |
+
))
|
| 786 |
+
);
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
#[test]
|
| 790 |
+
fn test_streaming_patch_parser_rejects_content_after_end_patch() {
|
| 791 |
+
let mut parser = StreamingPatchParser::default();
|
| 792 |
+
assert_eq!(
|
| 793 |
+
parser.push_delta(
|
| 794 |
+
"*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch\nextra\n",
|
| 795 |
+
),
|
| 796 |
+
Err(InvalidPatchError(
|
| 797 |
+
"The last line of the patch must be '*** End Patch'".to_string(),
|
| 798 |
+
))
|
| 799 |
+
);
|
| 800 |
+
|
| 801 |
+
let mut parser = StreamingPatchParser::default();
|
| 802 |
+
assert_eq!(
|
| 803 |
+
parser.push_delta(
|
| 804 |
+
"*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch\n \t\n",
|
| 805 |
+
),
|
| 806 |
+
Ok(vec![AddFile {
|
| 807 |
+
path: PathBuf::from("file.txt"),
|
| 808 |
+
contents: "hello\n".to_string(),
|
| 809 |
+
}])
|
| 810 |
+
);
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
#[test]
|
| 814 |
+
fn test_streaming_patch_parser_returns_errors() {
|
| 815 |
+
let mut parser = StreamingPatchParser::default();
|
| 816 |
+
assert_eq!(
|
| 817 |
+
parser.push_delta("bad\n"),
|
| 818 |
+
Err(InvalidPatchError(
|
| 819 |
+
"The first line of the patch must be '*** Begin Patch'".to_string(),
|
| 820 |
+
))
|
| 821 |
+
);
|
| 822 |
+
|
| 823 |
+
let mut parser = StreamingPatchParser::default();
|
| 824 |
+
assert_eq!(parser.push_delta("*** Begin Patch\n"), Ok(Vec::new()));
|
| 825 |
+
assert_eq!(
|
| 826 |
+
parser.push_delta("bad\n"),
|
| 827 |
+
Err(InvalidHunkError {
|
| 828 |
+
message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'"
|
| 829 |
+
.to_string(),
|
| 830 |
+
line_number: 2,
|
| 831 |
+
})
|
| 832 |
+
);
|
| 833 |
+
|
| 834 |
+
let mut parser = StreamingPatchParser::default();
|
| 835 |
+
assert_eq!(
|
| 836 |
+
parser.push_delta("*** Begin Patch\n*** Add File: file.txt\nbad\n"),
|
| 837 |
+
Err(InvalidHunkError {
|
| 838 |
+
message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'"
|
| 839 |
+
.to_string(),
|
| 840 |
+
line_number: 3,
|
| 841 |
+
})
|
| 842 |
+
);
|
| 843 |
+
|
| 844 |
+
let mut parser = StreamingPatchParser::default();
|
| 845 |
+
assert_eq!(
|
| 846 |
+
parser.push_delta("*** Begin Patch\n*** Delete File: file.txt\nbad\n"),
|
| 847 |
+
Err(InvalidHunkError {
|
| 848 |
+
message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'"
|
| 849 |
+
.to_string(),
|
| 850 |
+
line_number: 3,
|
| 851 |
+
})
|
| 852 |
+
);
|
| 853 |
+
|
| 854 |
+
let mut parser = StreamingPatchParser::default();
|
| 855 |
+
assert_eq!(
|
| 856 |
+
parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n*** End Patch\n"),
|
| 857 |
+
Err(InvalidHunkError {
|
| 858 |
+
message: "Update file hunk for path 'file.txt' is empty".to_string(),
|
| 859 |
+
line_number: 2,
|
| 860 |
+
})
|
| 861 |
+
);
|
| 862 |
+
|
| 863 |
+
let mut parser = StreamingPatchParser::default();
|
| 864 |
+
assert_eq!(
|
| 865 |
+
parser.push_delta(
|
| 866 |
+
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n*** Delete File: other.txt\n",
|
| 867 |
+
),
|
| 868 |
+
Err(InvalidHunkError {
|
| 869 |
+
message: "Update file hunk for path 'old.txt' is empty".to_string(),
|
| 870 |
+
line_number: 2,
|
| 871 |
+
})
|
| 872 |
+
);
|
| 873 |
+
|
| 874 |
+
let mut parser = StreamingPatchParser::default();
|
| 875 |
+
assert_eq!(
|
| 876 |
+
parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End Patch\n"),
|
| 877 |
+
Err(InvalidHunkError {
|
| 878 |
+
message: "Update hunk does not contain any lines".to_string(),
|
| 879 |
+
line_number: 4,
|
| 880 |
+
})
|
| 881 |
+
);
|
| 882 |
+
|
| 883 |
+
let mut parser = StreamingPatchParser::default();
|
| 884 |
+
assert_eq!(
|
| 885 |
+
parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End of File\n"),
|
| 886 |
+
Err(InvalidHunkError {
|
| 887 |
+
message: "Update hunk does not contain any lines".to_string(),
|
| 888 |
+
line_number: 4,
|
| 889 |
+
})
|
| 890 |
+
);
|
| 891 |
+
|
| 892 |
+
let mut parser = StreamingPatchParser::default();
|
| 893 |
+
assert_eq!(
|
| 894 |
+
parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n@@\n"),
|
| 895 |
+
Err(InvalidHunkError {
|
| 896 |
+
message: "Unexpected line found in update hunk: '@@'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
|
| 897 |
+
.to_string(),
|
| 898 |
+
line_number: 4,
|
| 899 |
+
})
|
| 900 |
+
);
|
| 901 |
+
|
| 902 |
+
let mut parser = StreamingPatchParser::default();
|
| 903 |
+
assert_eq!(
|
| 904 |
+
parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\nbad\n"),
|
| 905 |
+
Err(InvalidHunkError {
|
| 906 |
+
message: "Expected update hunk to start with a @@ context marker, got: 'bad'"
|
| 907 |
+
.to_string(),
|
| 908 |
+
line_number: 5,
|
| 909 |
+
})
|
| 910 |
+
);
|
| 911 |
+
|
| 912 |
+
let mut parser = StreamingPatchParser::default();
|
| 913 |
+
assert_eq!(
|
| 914 |
+
parser.push_delta(
|
| 915 |
+
"*** Begin Patch\n*** Update File: file.txt\n@@\n*** Update File: other.txt\n",
|
| 916 |
+
),
|
| 917 |
+
Err(InvalidHunkError {
|
| 918 |
+
message: "Unexpected line found in update hunk: '*** Update File: other.txt'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
|
| 919 |
+
.to_string(),
|
| 920 |
+
line_number: 4,
|
| 921 |
+
})
|
| 922 |
+
);
|
| 923 |
+
}
|
| 924 |
+
}
|
codex-rs/apply-patch/src/text_file.rs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pub(super) type Replacement = (usize, usize, Vec<String>);
|
| 2 |
+
|
| 3 |
+
#[derive(Clone, Copy)]
|
| 4 |
+
enum LineEnding {
|
| 5 |
+
Lf,
|
| 6 |
+
CrLf,
|
| 7 |
+
Cr,
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
impl LineEnding {
|
| 11 |
+
fn as_str(self) -> &'static str {
|
| 12 |
+
match self {
|
| 13 |
+
Self::Lf => "\n",
|
| 14 |
+
Self::CrLf => "\r\n",
|
| 15 |
+
Self::Cr => "\r",
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
struct SourceLine {
|
| 21 |
+
text: String,
|
| 22 |
+
ending: Option<LineEnding>,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
pub(super) struct SourceFile {
|
| 26 |
+
lines: Vec<SourceLine>,
|
| 27 |
+
preferred_ending: LineEnding,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
impl SourceFile {
|
| 31 |
+
/// Splits contents into logical lines while retaining each line ending.
|
| 32 |
+
///
|
| 33 |
+
/// The first existing ending becomes the preferred style for inserted
|
| 34 |
+
/// lines; files without an ending default to LF.
|
| 35 |
+
pub(super) fn parse(contents: &str) -> Self {
|
| 36 |
+
let mut lines = Vec::new();
|
| 37 |
+
let mut preferred_ending = None;
|
| 38 |
+
let mut line_start = 0;
|
| 39 |
+
let mut cursor = 0;
|
| 40 |
+
|
| 41 |
+
while cursor < contents.len() {
|
| 42 |
+
let (ending, ending_len) = match contents.as_bytes()[cursor] {
|
| 43 |
+
b'\r' if contents.as_bytes().get(cursor + 1) == Some(&b'\n') => {
|
| 44 |
+
(LineEnding::CrLf, 2)
|
| 45 |
+
}
|
| 46 |
+
b'\r' => (LineEnding::Cr, 1),
|
| 47 |
+
b'\n' => (LineEnding::Lf, 1),
|
| 48 |
+
_ => {
|
| 49 |
+
cursor += 1;
|
| 50 |
+
continue;
|
| 51 |
+
}
|
| 52 |
+
};
|
| 53 |
+
preferred_ending.get_or_insert(ending);
|
| 54 |
+
lines.push(SourceLine {
|
| 55 |
+
text: contents[line_start..cursor].to_string(),
|
| 56 |
+
ending: Some(ending),
|
| 57 |
+
});
|
| 58 |
+
cursor += ending_len;
|
| 59 |
+
line_start = cursor;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
if line_start < contents.len() {
|
| 63 |
+
lines.push(SourceLine {
|
| 64 |
+
text: contents[line_start..].to_string(),
|
| 65 |
+
ending: None,
|
| 66 |
+
});
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
Self {
|
| 70 |
+
lines,
|
| 71 |
+
preferred_ending: preferred_ending.unwrap_or(LineEnding::Lf),
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
pub(super) fn line_texts(&self) -> Vec<String> {
|
| 76 |
+
self.lines.iter().map(|line| line.text.clone()).collect()
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/// Rebuilds the file from source-ordered, non-overlapping replacements.
|
| 80 |
+
///
|
| 81 |
+
/// Unchanged lines retain their original endings, inserted lines use the
|
| 82 |
+
/// preferred ending, and every resulting line receives an ending to match
|
| 83 |
+
/// apply-patch's historical trailing-newline behavior.
|
| 84 |
+
pub(super) fn apply_replacements(&mut self, replacements: &[Replacement]) {
|
| 85 |
+
let mut source_lines = std::mem::take(&mut self.lines).into_iter();
|
| 86 |
+
let mut new_lines = Vec::new();
|
| 87 |
+
let mut source_index = 0;
|
| 88 |
+
|
| 89 |
+
for (start_idx, old_len, new_segment) in replacements {
|
| 90 |
+
debug_assert!(*start_idx >= source_index);
|
| 91 |
+
for line in source_lines.by_ref().take(*start_idx - source_index) {
|
| 92 |
+
new_lines.push(line);
|
| 93 |
+
}
|
| 94 |
+
for _ in source_lines.by_ref().take(*old_len) {}
|
| 95 |
+
new_lines.extend(new_segment.iter().map(|text| SourceLine {
|
| 96 |
+
text: text.clone(),
|
| 97 |
+
ending: Some(self.preferred_ending),
|
| 98 |
+
}));
|
| 99 |
+
source_index = start_idx + old_len;
|
| 100 |
+
}
|
| 101 |
+
new_lines.extend(source_lines);
|
| 102 |
+
self.lines = new_lines;
|
| 103 |
+
|
| 104 |
+
// Updates have historically added a trailing newline. This also gives
|
| 105 |
+
// an unterminated last line an ending if an insertion moved it inward.
|
| 106 |
+
for line in &mut self.lines {
|
| 107 |
+
line.ending.get_or_insert(self.preferred_ending);
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
pub(super) fn into_contents(self) -> String {
|
| 112 |
+
let mut contents = String::new();
|
| 113 |
+
for line in self.lines {
|
| 114 |
+
contents.push_str(&line.text);
|
| 115 |
+
if let Some(ending) = line.ending {
|
| 116 |
+
contents.push_str(ending.as_str());
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
contents
|
| 120 |
+
}
|
| 121 |
+
}
|
codex-rs/apply-patch/tests/all.rs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Single integration test binary that aggregates all test modules.
|
| 2 |
+
// The submodules live in `tests/suite/`.
|
| 3 |
+
mod suite;
|
codex-rs/apply-patch/tests/fixtures/scenarios/.gitattributes
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
** text eol=lf
|
| 2 |
+
023_preserves_crlf_line_endings/input/*.txt -diff -text
|
| 3 |
+
023_preserves_crlf_line_endings/expected/*.txt -diff -text
|
| 4 |
+
024_preserves_mixed_line_endings/input/*.txt -diff -text
|
| 5 |
+
024_preserves_mixed_line_endings/expected/*.txt -diff -text
|
codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/expected/bar.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
This is a new file
|
codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/patch.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*** Begin Patch
|
| 2 |
+
*** Add File: bar.md
|
| 3 |
+
+This is a new file
|
| 4 |
+
*** End Patch
|
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/modify.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
line1
|
| 2 |
+
changed
|
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/nested/new.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
created
|
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/delete.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
obsolete
|
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/modify.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
line1
|
| 2 |
+
line2
|
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/patch.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*** Begin Patch
|
| 2 |
+
*** Add File: nested/new.txt
|
| 3 |
+
+created
|
| 4 |
+
*** Delete File: delete.txt
|
| 5 |
+
*** Update File: modify.txt
|
| 6 |
+
@@
|
| 7 |
+
-line2
|
| 8 |
+
+changed
|
| 9 |
+
*** End Patch
|
codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/expected/multi.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
line1
|
| 2 |
+
changed2
|
| 3 |
+
line3
|
| 4 |
+
changed4
|
codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/input/multi.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
line1
|
| 2 |
+
line2
|
| 3 |
+
line3
|
| 4 |
+
line4
|
codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/patch.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*** Begin Patch
|
| 2 |
+
*** Update File: multi.txt
|
| 3 |
+
@@
|
| 4 |
+
-line2
|
| 5 |
+
+changed2
|
| 6 |
+
@@
|
| 7 |
+
-line4
|
| 8 |
+
+changed4
|
| 9 |
+
*** End Patch
|
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/old/other.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
unrelated file
|
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/renamed/dir/name.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
new content
|
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/name.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
old content
|
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/other.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
unrelated file
|
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/patch.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*** Begin Patch
|
| 2 |
+
*** Update File: old/name.txt
|
| 3 |
+
*** Move to: renamed/dir/name.txt
|
| 4 |
+
@@
|
| 5 |
+
-old content
|
| 6 |
+
+new content
|
| 7 |
+
*** End Patch
|
codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/expected/foo.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
stable
|
codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/input/foo.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
stable
|
codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/patch.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*** Begin Patch
|
| 2 |
+
*** End Patch
|
codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/expected/modify.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
line1
|
| 2 |
+
line2
|
codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/input/modify.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
line1
|
| 2 |
+
line2
|
codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/patch.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*** Begin Patch
|
| 2 |
+
*** Update File: modify.txt
|
| 3 |
+
@@
|
| 4 |
+
-missing
|
| 5 |
+
+changed
|
| 6 |
+
*** End Patch
|
codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/expected/foo.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
stable
|
codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/input/foo.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
stable
|
codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/patch.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*** Begin Patch
|
| 2 |
+
*** Delete File: missing.txt
|
| 3 |
+
*** End Patch
|
codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/expected/foo.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
stable
|
codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/input/foo.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
stable
|
codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/patch.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*** Begin Patch
|
| 2 |
+
*** Update File: foo.txt
|
| 3 |
+
*** End Patch
|
codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/expected/foo.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
stable
|
codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/input/foo.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
stable
|
codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/patch.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*** Begin Patch
|
| 2 |
+
*** Update File: missing.txt
|
| 3 |
+
@@
|
| 4 |
+
-old
|
| 5 |
+
+new
|
| 6 |
+
*** End Patch
|
codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/old/other.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
unrelated file
|
codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/renamed/dir/name.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
new
|
codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/name.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from
|