1use super::blob_apply::{apply_patch_to_blob, BlobApplyOptions};
4use super::FilePatch;
5use crate::error::{ApplyError, Error};
6use crate::index::{Index, IndexEntry, MODE_REGULAR};
7use crate::merge_diff::read_blob_at_path;
8use crate::objects::{ObjectId, ObjectKind};
9use crate::repo::Repository;
10use std::collections::BTreeMap;
11
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
14pub struct IndexApplyOptions {
15 pub dry_run: bool,
17 pub reverse: bool,
19 pub head_tree_oid: Option<ObjectId>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Default)]
25pub struct IndexApplyOutcome {
26 pub added: Vec<String>,
28 pub modified: Vec<String>,
30 pub removed: Vec<String>,
32 pub dry_run: bool,
34}
35
36impl IndexApplyOutcome {
37 fn push_added(&mut self, path: String) {
38 if !self.added.contains(&path) {
39 self.added.push(path);
40 }
41 }
42
43 fn push_modified(&mut self, path: String) {
44 if !self.modified.contains(&path) && !self.added.contains(&path) {
45 self.modified.push(path);
46 }
47 }
48
49 fn push_removed(&mut self, path: String) {
50 if !self.removed.contains(&path) {
51 self.removed.push(path);
52 }
53 }
54
55 fn finalize(mut self) -> Self {
56 self.added.sort();
57 self.modified.sort();
58 self.removed.sort();
59 self
60 }
61}
62
63#[derive(Clone)]
64enum PlannedChange {
65 Upsert {
66 path: String,
67 oid: ObjectId,
68 mode: u32,
69 blob: Vec<u8>,
70 },
71 Remove {
72 path: String,
73 },
74}
75
76pub fn apply_patch_to_index(
80 repo: &Repository,
81 index: &mut Index,
82 patches: &[FilePatch],
83 opts: &IndexApplyOptions,
84) -> std::result::Result<IndexApplyOutcome, ApplyError> {
85 let mut trial: Option<Index> = None;
88 let mut outcome = IndexApplyOutcome {
89 dry_run: opts.dry_run,
90 ..IndexApplyOutcome::default()
91 };
92 let mut fn_table: BTreeMap<String, FnState> = BTreeMap::new();
93 let mut planned = Vec::new();
94
95 for (i, patch) in patches.iter().enumerate() {
96 if patch.involves_gitlink() {
97 let path = patch.effective_path().unwrap_or("<unknown>").to_string();
98 return Err(ApplyError::Failed {
99 path,
100 message: "submodule/gitlink patches are not supported".to_string(),
101 });
102 }
103 let view = trial.as_ref().unwrap_or(index);
104 let changes = plan_one_file_patch(repo, view, patch, opts, &mut fn_table, &mut outcome)?;
105 if i + 1 < patches.len() {
106 let trial = trial.get_or_insert_with(|| index.clone());
107 for change in &changes {
108 apply_planned_to_index(trial, change);
109 }
110 }
111 planned.extend(changes);
112 }
113
114 if !opts.dry_run {
115 for change in &planned {
116 if let PlannedChange::Upsert { blob, .. } = change {
117 repo.odb()
118 .write(ObjectKind::Blob, blob)
119 .map_err(|e| library_err_path("", e))?;
120 }
121 }
122 for change in &planned {
123 apply_planned_to_index(index, change);
124 }
125 }
126 Ok(outcome.finalize())
127}
128
129pub fn apply_patch_to_index_and_write(
131 repo: &Repository,
132 patches: &[FilePatch],
133 opts: &IndexApplyOptions,
134) -> std::result::Result<IndexApplyOutcome, ApplyError> {
135 let handle = repo.index_handle();
136 let mut lock = handle.begin_update().map_err(|e| library_err_path("", e))?;
137 let mut index = handle.load().map_err(|e| library_err_path("", e))?;
138 let outcome = apply_patch_to_index(repo, &mut index, patches, opts)?;
139 if opts.dry_run {
140 return Ok(outcome);
141 }
142 handle
143 .commit_update(&mut lock, &mut index, false)
144 .map_err(|e| library_err_path("", e))?;
145 Ok(outcome)
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149enum FnState {
150 Active,
151 Deleted,
152}
153
154fn plan_one_file_patch(
155 repo: &Repository,
156 index: &Index,
157 patch: &FilePatch,
158 opts: &IndexApplyOptions,
159 fn_table: &mut BTreeMap<String, FnState>,
160 outcome: &mut IndexApplyOutcome,
161) -> std::result::Result<Vec<PlannedChange>, ApplyError> {
162 let target = patch
163 .target_path()
164 .map(str::to_string)
165 .or_else(|| patch.effective_path().map(str::to_string))
166 .ok_or_else(|| ApplyError::Failed {
167 path: String::new(),
168 message: "patch has no target path".to_string(),
169 })?;
170
171 if patch.is_deleted {
172 if opts.reverse {
173 let base_blob = read_preimage_blob(repo, index, patch, &target, opts)?;
174 let new_blob = apply_patch_to_blob(
175 &base_blob,
176 patch,
177 &BlobApplyOptions {
178 reverse: true,
179 ..BlobApplyOptions::default()
180 },
181 )?;
182 return finish_reverse_index_upsert(
183 repo, index, patch, &target, opts, fn_table, outcome, new_blob,
184 );
185 }
186 let base_blob = read_preimage_blob(repo, index, patch, &target, opts)?;
187 apply_patch_to_blob(
188 &base_blob,
189 patch,
190 &BlobApplyOptions {
191 reverse: opts.reverse,
192 ..BlobApplyOptions::default()
193 },
194 )?;
195 if index.get(target.as_bytes(), 0).is_none() {
196 return Err(ApplyError::NotInIndex {
197 path: target.clone(),
198 });
199 }
200 fn_table.insert(target.clone(), FnState::Deleted);
201 outcome.push_removed(target.clone());
202 return Ok(vec![PlannedChange::Remove { path: target }]);
203 }
204
205 if patch.is_new
206 && index.get(target.as_bytes(), 0).is_some()
207 && fn_table.get(&target).copied() != Some(FnState::Deleted)
208 {
209 return Err(ApplyError::Failed {
210 path: target.clone(),
211 message: "path already exists in index".to_string(),
212 });
213 }
214
215 let base_blob = read_preimage_blob(repo, index, patch, &target, opts)?;
216 let new_blob = apply_patch_to_blob(
217 &base_blob,
218 patch,
219 &BlobApplyOptions {
220 reverse: opts.reverse,
221 ..BlobApplyOptions::default()
222 },
223 )?;
224 if opts.reverse {
225 return finish_reverse_index_upsert(
226 repo, index, patch, &target, opts, fn_table, outcome, new_blob,
227 );
228 }
229 let mode = resolve_mode(index, patch, &target)?;
230 let oid = hash_blob(repo, &new_blob)?;
231
232 let mut out = Vec::new();
233 if patch.is_rename {
234 if let Some(old) = patch.source_path().map(str::to_string) {
235 if old != target
236 && fn_table.get(&old).copied() != Some(FnState::Deleted)
237 && index.get(old.as_bytes(), 0).is_some()
238 {
239 fn_table.insert(old.clone(), FnState::Deleted);
240 outcome.push_removed(old.clone());
241 out.push(PlannedChange::Remove { path: old });
242 }
243 }
244 }
245
246 let existed = index.get(target.as_bytes(), 0).is_some()
247 || fn_table.get(&target).copied() == Some(FnState::Active);
248 if existed {
249 outcome.push_modified(target.clone());
250 } else {
251 outcome.push_added(target.clone());
252 }
253 fn_table.insert(target.clone(), FnState::Active);
254
255 out.push(PlannedChange::Upsert {
256 path: target,
257 oid,
258 mode,
259 blob: new_blob,
260 });
261 Ok(out)
262}
263
264fn apply_planned_to_index(index: &mut Index, change: &PlannedChange) {
265 match change {
266 PlannedChange::Remove { path } => {
267 index.remove_path_all_stages(path.as_bytes());
268 }
269 PlannedChange::Upsert {
270 path, oid, mode, ..
271 } => {
272 upsert_index_entry(index, path, *oid, *mode);
273 }
274 }
275}
276
277fn read_preimage_blob(
278 repo: &Repository,
279 index: &Index,
280 patch: &FilePatch,
281 target: &str,
282 opts: &IndexApplyOptions,
283) -> std::result::Result<Vec<u8>, ApplyError> {
284 if patch.is_new {
285 return Ok(Vec::new());
286 }
287 let path = patch
288 .source_path()
289 .or_else(|| patch.effective_path())
290 .unwrap_or(target);
291 if path == "/dev/null" {
292 return Ok(Vec::new());
293 }
294 if let Some(entry) = index.get(path.as_bytes(), 0) {
295 return repo
296 .odb()
297 .read(&entry.oid)
298 .map(|o| o.data)
299 .map_err(|e| ApplyError::Failed {
300 path: path.to_string(),
301 message: e.to_string(),
302 });
303 }
304 if opts.reverse {
305 return Ok(Vec::new());
306 }
307 Err(ApplyError::NotInIndex {
308 path: path.to_string(),
309 })
310}
311
312#[allow(clippy::too_many_arguments)]
313fn finish_reverse_index_upsert(
314 repo: &Repository,
315 index: &Index,
316 patch: &FilePatch,
317 target: &str,
318 opts: &IndexApplyOptions,
319 fn_table: &mut BTreeMap<String, FnState>,
320 outcome: &mut IndexApplyOutcome,
321 new_blob: Vec<u8>,
322) -> std::result::Result<Vec<PlannedChange>, ApplyError> {
323 if should_drop_index_entry_on_reverse(repo, target, opts, &new_blob) {
324 if index.get(target.as_bytes(), 0).is_some()
325 || fn_table.get(target).copied() == Some(FnState::Active)
326 {
327 fn_table.insert(target.to_string(), FnState::Deleted);
328 outcome.push_removed(target.to_string());
329 return Ok(vec![PlannedChange::Remove {
330 path: target.to_string(),
331 }]);
332 }
333 return Ok(Vec::new());
334 }
335 let mode = resolve_mode(index, patch, target)?;
336 let oid = hash_blob(repo, &new_blob)?;
337 let existed = index.get(target.as_bytes(), 0).is_some()
338 || fn_table.get(target).copied() == Some(FnState::Active);
339 if existed {
340 outcome.push_modified(target.to_string());
341 } else {
342 outcome.push_added(target.to_string());
343 }
344 fn_table.insert(target.to_string(), FnState::Active);
345 Ok(vec![PlannedChange::Upsert {
346 path: target.to_string(),
347 oid,
348 mode,
349 blob: new_blob,
350 }])
351}
352
353fn should_drop_index_entry_on_reverse(
354 repo: &Repository,
355 path: &str,
356 opts: &IndexApplyOptions,
357 new_blob: &[u8],
358) -> bool {
359 if !new_blob.is_empty() {
360 return false;
361 }
362 let Some(tree) = opts.head_tree_oid else {
363 return true;
364 };
365 read_blob_at_path(repo.odb(), &tree, path).is_none()
366}
367
368fn resolve_mode(
369 index: &Index,
370 patch: &FilePatch,
371 target: &str,
372) -> std::result::Result<u32, ApplyError> {
373 if let Some(mode) = patch.new_mode.as_deref() {
374 return parse_mode(mode);
375 }
376 if patch.is_rename || patch.is_copy {
377 if let Some(src) = patch
378 .source_path()
379 .filter(|p| *p != "/dev/null")
380 .or_else(|| patch.old_path.as_deref().filter(|p| *p != "/dev/null"))
381 {
382 if let Some(entry) = index.get(src.as_bytes(), 0) {
383 return Ok(entry.mode);
384 }
385 }
386 }
387 if let Some(mode) = patch.old_mode.as_deref() {
388 return parse_mode(mode);
389 }
390 if let Some(entry) = index.get(target.as_bytes(), 0) {
391 return Ok(entry.mode);
392 }
393 Ok(MODE_REGULAR)
394}
395
396fn parse_mode(mode: &str) -> std::result::Result<u32, ApplyError> {
397 let trimmed = mode.trim();
398 u32::from_str_radix(trimmed, 8).map_err(|e| ApplyError::Failed {
399 path: String::new(),
400 message: format!("invalid mode '{trimmed}': {e}"),
401 })
402}
403
404fn hash_blob(repo: &Repository, data: &[u8]) -> std::result::Result<ObjectId, ApplyError> {
405 repo.odb()
406 .hash_algo()
407 .hash_object(ObjectKind::Blob, data)
408 .map_err(|e| ApplyError::Failed {
409 path: String::new(),
410 message: e.to_string(),
411 })
412}
413
414fn upsert_index_entry(index: &mut Index, path: &str, oid: ObjectId, mode: u32) {
415 let path_bytes = path.as_bytes().to_vec();
416 let flags = (path.len().min(0xfff) as u16) & 0x0fff;
417 let entry = IndexEntry {
418 ctime_sec: 0,
419 ctime_nsec: 0,
420 mtime_sec: 0,
421 mtime_nsec: 0,
422 dev: 0,
423 ino: 0,
424 mode: if mode == 0 { MODE_REGULAR } else { mode },
425 uid: 0,
426 gid: 0,
427 size: 0,
428 oid,
429 flags,
430 flags_extended: None,
431 path: path_bytes,
432 base_index_pos: 0,
433 };
434 index.add_or_replace(entry);
435}
436
437fn library_err_path(path: &str, err: Error) -> ApplyError {
438 ApplyError::Failed {
439 path: path.to_string(),
440 message: err.to_string(),
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::apply::parse_patch;
448 use crate::config::environment::RepositoryOptions;
449 use crate::repo::Repository;
450 use std::process::Command;
451
452 fn init_repo(dir: &std::path::Path) {
453 Command::new("git")
454 .current_dir(dir)
455 .args(["init", "-q", "-b", "main"])
456 .status()
457 .unwrap();
458 }
459
460 #[cfg_attr(
461 not(feature = "full-tests"),
462 ignore = "starts system git or is heavy; run with --features full-tests"
463 )]
464 #[test]
465 fn index_apply_modify() {
466 let dir = tempfile::tempdir().unwrap();
467 init_repo(dir.path());
468 std::fs::write(dir.path().join("f.txt"), b"a\n").unwrap();
469 Command::new("git")
470 .current_dir(dir.path())
471 .args(["add", "f.txt"])
472 .status()
473 .unwrap();
474 let patch = parse_patch(
475 "diff --git a/f.txt b/f.txt\n\
476 --- a/f.txt\n\
477 +++ b/f.txt\n\
478 @@ -1 +1 @@\n\
479 -a\n\
480 +b\n",
481 1,
482 "p",
483 false,
484 None,
485 )
486 .unwrap();
487 let repo = Repository::discover(Some(dir.path()), &RepositoryOptions::default()).unwrap();
488 let mut index = repo.index_handle().load().unwrap();
489 apply_patch_to_index(&repo, &mut index, &patch, &IndexApplyOptions::default()).unwrap();
490 let entry = index.get(b"f.txt", 0).unwrap();
491 let blob = repo.odb().read(&entry.oid).unwrap();
492 assert_eq!(blob.data, b"b\n");
493 assert!(entry.stat_cache_unset());
494 }
495
496 fn one_line_patch(path: &str, old: &str, new: &str) -> FilePatch {
497 let text = format!(
498 "diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1 +1 @@\n-{old}\n+{new}\n"
499 );
500 parse_patch(&text, 1, "p", false, None)
501 .unwrap()
502 .pop()
503 .unwrap()
504 }
505
506 fn repo_with_index(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repository, Index) {
507 let dir = tempfile::tempdir().unwrap();
508 let repo =
509 Repository::init(dir.path(), &crate::repo::InitOptions::default()).expect("init");
510 let mut index = Index::new();
511 for (path, data) in files {
512 let oid = repo.odb().write(ObjectKind::Blob, data).unwrap();
513 upsert_index_entry(&mut index, path, oid, MODE_REGULAR);
514 }
515 (dir, repo, index)
516 }
517
518 fn blob_at(repo: &Repository, index: &Index, path: &str) -> Vec<u8> {
519 let entry = index.get(path.as_bytes(), 0).expect("entry");
520 repo.odb().read(&entry.oid).unwrap().data
521 }
522
523 #[test]
524 fn index_apply_several_patches_in_one_call() {
525 let (_dir, repo, mut index) =
526 repo_with_index(&[("a.txt", b"a\n"), ("b.txt", b"b\n"), ("c.txt", b"c\n")]);
527 let patches = [
528 one_line_patch("a.txt", "a", "A"),
529 one_line_patch("c.txt", "c", "C"),
530 ];
531 let outcome =
532 apply_patch_to_index(&repo, &mut index, &patches, &IndexApplyOptions::default())
533 .unwrap();
534 assert_eq!(outcome.modified, ["a.txt", "c.txt"]);
535 assert_eq!(blob_at(&repo, &index, "a.txt"), b"A\n");
536 assert_eq!(blob_at(&repo, &index, "b.txt"), b"b\n");
537 assert_eq!(blob_at(&repo, &index, "c.txt"), b"C\n");
538 }
539
540 #[test]
541 fn index_apply_failure_in_later_patch_leaves_index_untouched() {
542 let (_dir, repo, mut index) = repo_with_index(&[("a.txt", b"a\n"), ("b.txt", b"b\n")]);
543 let before = index.clone();
544 let patches = [
545 one_line_patch("a.txt", "a", "A"),
546 one_line_patch("b.txt", "not-b", "B"),
547 ];
548 let err = apply_patch_to_index(&repo, &mut index, &patches, &IndexApplyOptions::default())
549 .expect_err("second patch does not apply");
550 assert!(matches!(err, ApplyError::PatchFailed { .. }), "{err:?}");
551 assert_eq!(index.entries, before.entries);
552 let a_oid = repo
553 .odb()
554 .hash_algo()
555 .hash_object(ObjectKind::Blob, b"A\n")
556 .unwrap();
557 assert!(!repo.odb().exists(&a_oid), "no blob written on failure");
558 }
559
560 #[test]
561 fn index_apply_dry_run_reports_without_changing_index() {
562 let (_dir, repo, mut index) = repo_with_index(&[("a.txt", b"a\n")]);
563 let before = index.clone();
564 let outcome = apply_patch_to_index(
565 &repo,
566 &mut index,
567 &[one_line_patch("a.txt", "a", "A")],
568 &IndexApplyOptions {
569 dry_run: true,
570 ..IndexApplyOptions::default()
571 },
572 )
573 .unwrap();
574 assert!(outcome.dry_run);
575 assert_eq!(outcome.modified, ["a.txt"]);
576 assert_eq!(index.entries, before.entries);
577 }
578}