Skip to main content

lattice_vcs/
index.rs

1use std::path::Path;
2
3use crate::{Repository, Result, VcsError};
4
5/// Index (staging area) write operations.
6pub struct Index;
7
8impl Index {
9    /// Stage a file path (equivalent to `git add <path>`).
10    pub fn stage_path(repo: &Repository, path: impl AsRef<Path>) -> Result<()> {
11        Self::stage_paths(repo, [path.as_ref()])
12    }
13
14    /// Stage every path in ONE `git add`.
15    ///
16    /// Not a convenience wrapper over [`Self::stage_path`] — the number
17    /// of git invocations is the point. Each one spawns a process and
18    /// takes `.git/index.lock` for the duration, so staging N files as N
19    /// commands is N process spawns and N lock cycles, every one of them
20    /// a window in which any other git operation in the editor fails
21    /// with "Unable to create index.lock: File exists" (reported
22    /// 2026-08-16 while staging a visual-mode selection).
23    ///
24    /// One command is also ATOMIC where the loop was not: a loop can
25    /// fail partway and leave half the selection staged, which is why
26    /// the magit layer had to model "3 of 5 staged" as an outcome at
27    /// all.
28    ///
29    /// Mirrors [`Self::unstage_paths`], which has always been one
30    /// command — it had to be, because a staged rename occupies two
31    /// index entries that must reset together.
32    pub fn stage_paths<I, P>(repo: &Repository, paths: I) -> Result<()>
33    where
34        I: IntoIterator<Item = P>,
35        P: AsRef<Path>,
36    {
37        let owned: Vec<String> = paths
38            .into_iter()
39            .map(|p| p.as_ref().to_string_lossy().into_owned())
40            .collect();
41        if owned.is_empty() {
42            return Ok(());
43        }
44        let mut args: Vec<&str> = vec!["add", "--"];
45        args.extend(owned.iter().map(String::as_str));
46        repo.run_git(args)
47            .map(|_| ())
48            .map_err(|e| VcsError::Index(format!("stage_paths {}: {}", owned.join(" "), e)))
49    }
50
51    /// Unstage a file path (equivalent to `git reset HEAD -- <path>`).
52    pub fn unstage_path(repo: &Repository, path: impl AsRef<Path>) -> Result<()> {
53        Self::unstage_paths(repo, [path.as_ref()])
54    }
55
56    /// Unstage every path in one `git reset`, which is what a RENAME
57    /// requires.
58    ///
59    /// A staged rename occupies TWO index entries — the new path added
60    /// and the old one deleted — and resetting only the new one leaves
61    /// `D  old` still staged: a deletion the user never asked for, and
62    /// one the next commit would record. Resetting both together
63    /// returns the index to HEAD, leaving the rename visible in the
64    /// worktree as a delete plus an untracked file, which is exactly
65    /// how git reports an unstaged rename (it does not detect them).
66    pub fn unstage_paths<I, P>(repo: &Repository, paths: I) -> Result<()>
67    where
68        I: IntoIterator<Item = P>,
69        P: AsRef<Path>,
70    {
71        let owned: Vec<String> = paths
72            .into_iter()
73            .map(|p| p.as_ref().to_string_lossy().into_owned())
74            .collect();
75        if owned.is_empty() {
76            return Ok(());
77        }
78        let mut args: Vec<&str> = vec!["reset", "HEAD", "--"];
79        args.extend(owned.iter().map(String::as_str));
80        repo.run_git(args)
81            .map(|_| ())
82            .map_err(|e| VcsError::Index(format!("unstage_paths {}: {}", owned.join(" "), e)))
83    }
84
85    /// MG.18a: apply a unified-diff `patch` to the index (`cached`) or
86    /// the working tree, forward or `reverse`d. This is the unit of
87    /// **partial** staging — the caller synthesizes a patch containing
88    /// exactly the hunks (or the rewritten hunk) it wants to move, and
89    /// this applies it. Git has no "stage hunk N of path P" index
90    /// operation; `git add -p` builds a patch and pipes it to
91    /// `git apply --cached`, and so do we.
92    ///
93    /// | Caller intent | `cached` | `reverse` |
94    /// |---|---|---|
95    /// | stage a hunk | `true` | `false` |
96    /// | unstage a hunk | `true` | `true` |
97    /// | discard a hunk from the worktree | `false` | `true` |
98    ///
99    /// `git apply` requires every context line to match the target
100    /// exactly, which is the safety property we want: if the worktree
101    /// moved under a stale buffer, this fails loudly instead of staging
102    /// the wrong lines. Callers surface the error and refresh.
103    ///
104    /// Replaces the former `stage_hunk` / `unstage_hunk`, which took a
105    /// `hunk_index` they discarded and staged the whole file — a
106    /// signature that promised precision the body did not deliver. See
107    /// `docs/dev/architecture/magit-hunk-staging.md`.
108    pub fn apply_patch(repo: &Repository, patch: &str, cached: bool, reverse: bool) -> Result<()> {
109        let mut args: Vec<&str> = vec!["apply"];
110        if cached {
111            args.push("--cached");
112        }
113        if reverse {
114            args.push("--reverse");
115        }
116        // Read the patch from stdin rather than a temp file: a temp file
117        // leaks on crash and races a concurrent magit in the same repo.
118        args.push("-");
119        repo.run_git_stdin(args, patch.as_bytes())
120            .map(|_| ())
121            .map_err(|e| {
122                VcsError::Index(format!(
123                    "apply_patch (cached={cached}, reverse={reverse}): {e}"
124                ))
125            })
126    }
127}