Skip to main content

grit_lib/apply/
index_apply.rs

1//! Apply unified patches to the Git index (`git apply --cached` semantics).
2
3use 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/// Options for [`apply_patch_to_index`].
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
14pub struct IndexApplyOptions {
15    /// When true, validate and report paths without writing the index or objects.
16    pub dry_run: bool,
17    /// When true, reverse each patch before applying (`git apply --cached -R`).
18    pub reverse: bool,
19    /// When reversing, compare results to this tree to drop index entries that match `HEAD`.
20    pub head_tree_oid: Option<ObjectId>,
21}
22
23/// Result of applying patches to the index.
24#[derive(Debug, Clone, PartialEq, Eq, Default)]
25pub struct IndexApplyOutcome {
26    /// New paths added to the index.
27    pub added: Vec<String>,
28    /// Existing paths updated in the index.
29    pub modified: Vec<String>,
30    /// Paths removed from the index.
31    pub removed: Vec<String>,
32    /// Whether this was a dry run.
33    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
76/// Apply `patches` to `index` using index blobs as the preimage (never the worktree).
77///
78/// The operation is atomic: every patch is validated before any blob or index entry is written.
79pub 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    // Later patches are planned against a copy that already has the earlier patches applied;
86    // the last (often only) patch needs no copy, since nothing is planned after it.
87    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
129/// Load the index, apply `patches`, and commit the updated index under `.git/index.lock`.
130pub 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}