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}