lattice_vcs/note.rs
1//! MG.37: git notes — read, write, remove, prune, merge.
2//!
3//! The peer of [`crate::Stash`] / [`crate::Remote`] / [`crate::Submodule`]:
4//! a thin, typed wrapper over the git CLI.
5//!
6//! **Nothing here opens an editor.** `git notes edit` and
7//! `git notes add` (without `-F`/`-m`) both spawn `$EDITOR`, which
8//! inside this editor means a child process waiting on a terminal that
9//! is not there — the operation hangs holding a blocking-pool thread and
10//! never reports either way. [`Note::set`] takes the text and pipes it
11//! to `-F -` instead, which is what makes the notes buffer
12//! (`magit-notes-mode`) the editor rather than `$EDITOR`.
13
14use std::path::Path;
15
16use crate::{Repository, Result, VcsError};
17
18/// Git-notes operations.
19pub struct Note;
20
21/// How `git notes merge` resolves a conflict. Magit offers the same
22/// set, which is git's own.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum NoteMergeStrategy {
25 /// Stop and leave the conflict for the user to resolve, then
26 /// `--commit` or `--abort`. Git's default.
27 Manual,
28 Ours,
29 Theirs,
30 Union,
31 CatSortUniq,
32}
33
34impl NoteMergeStrategy {
35 /// The `--strategy=` value git expects.
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Self::Manual => "manual",
39 Self::Ours => "ours",
40 Self::Theirs => "theirs",
41 Self::Union => "union",
42 Self::CatSortUniq => "cat_sort_uniq",
43 }
44 }
45
46 /// Parse the name a user typed. `None` for anything else — the
47 /// caller says so rather than silently falling back to `manual`,
48 /// which would resolve a merge differently from what was asked.
49 pub fn parse(s: &str) -> Option<Self> {
50 match s.trim() {
51 "manual" => Some(Self::Manual),
52 "ours" => Some(Self::Ours),
53 "theirs" => Some(Self::Theirs),
54 "union" => Some(Self::Union),
55 "cat_sort_uniq" => Some(Self::CatSortUniq),
56 _ => None,
57 }
58 }
59}
60
61impl Note {
62 /// The note attached to `commit`, or `None` when there is none.
63 ///
64 /// `git notes show` exits non-zero for a commit with no note, which
65 /// is the ordinary case rather than a failure — hence `Option`, not
66 /// `Err`. A caller opening an edit buffer wants an empty buffer
67 /// there, not an error.
68 pub fn show(repo: &Repository, commit: &str) -> Option<String> {
69 repo.run_git_str(["notes", "show", commit]).ok()
70 }
71
72 /// Write `text` as `commit`'s note, replacing any existing one.
73 ///
74 /// `-F -` reads the note from stdin, the same seam
75 /// [`crate::Index::apply_patch`] uses and for the same reasons: a
76 /// temp file leaks on a crash and races a concurrent magit in the
77 /// same repository. (It also does not work here — a first attempt
78 /// wrote one and git reported "could not open or read" it.)
79 ///
80 /// `--force` because this is "set", not "add": the buffer was seeded
81 /// with the existing note, so refusing to overwrite would refuse
82 /// every edit after the first.
83 pub fn set(repo: &Repository, commit: &str, text: &str) -> Result<()> {
84 if text.trim().is_empty() {
85 // An empty buffer means "no note", and `git notes add -F` on
86 // empty input errors. Removing is the honest translation —
87 // and it is what the user asked for by clearing the buffer.
88 return Self::remove(repo, commit);
89 }
90 repo.run_git_stdin(
91 ["notes", "add", "--force", "-F", "-", commit],
92 text.as_bytes(),
93 )
94 .map(|_| ())
95 .map_err(|e| VcsError::Note(format!("notes add {commit}: {e}")))
96 }
97
98 /// Remove `commit`'s note.
99 ///
100 /// `--ignore-missing`: removing a note that is not there is what the
101 /// user asked for either way, and erroring would make "clear this
102 /// buffer and save" fail on a commit that never had one.
103 pub fn remove(repo: &Repository, commit: &str) -> Result<()> {
104 repo.run_git(["notes", "remove", "--ignore-missing", commit])
105 .map(|_| ())
106 .map_err(|e| VcsError::Note(format!("notes remove {commit}: {e}")))
107 }
108
109 /// Drop notes for objects that no longer exist.
110 ///
111 /// `dry_run` reports what would go without removing anything —
112 /// magit's `-n` on this transient, and worth having because the
113 /// operation is otherwise unreviewable.
114 pub fn prune(repo: &Repository, dry_run: bool) -> Result<String> {
115 let mut argv: Vec<&str> = vec!["notes", "prune"];
116 if dry_run {
117 argv.push("--dry-run");
118 }
119 repo.run_git_str(argv)
120 .map_err(|e| VcsError::Note(format!("notes prune: {e}")))
121 }
122
123 /// Merge the notes ref `from` into the current notes ref.
124 ///
125 /// With [`NoteMergeStrategy::Manual`] a conflict leaves the merge in
126 /// progress; [`Self::merge_commit`] / [`Self::merge_abort`] finish
127 /// it. The other strategies resolve without stopping.
128 pub fn merge(repo: &Repository, from: &str, strategy: NoteMergeStrategy) -> Result<String> {
129 repo.run_git_str([
130 "notes",
131 "merge",
132 &format!("--strategy={}", strategy.as_str()),
133 from,
134 ])
135 .map_err(|e| VcsError::Note(format!("notes merge {from}: {e}")))
136 }
137
138 /// Commit a notes merge that stopped on a conflict.
139 pub fn merge_commit(repo: &Repository) -> Result<()> {
140 repo.run_git(["notes", "merge", "--commit"])
141 .map(|_| ())
142 .map_err(|e| VcsError::Note(format!("notes merge --commit: {e}")))
143 }
144
145 /// Abandon a notes merge that stopped on a conflict.
146 pub fn merge_abort(repo: &Repository) -> Result<()> {
147 repo.run_git(["notes", "merge", "--abort"])
148 .map(|_| ())
149 .map_err(|e| VcsError::Note(format!("notes merge --abort: {e}")))
150 }
151
152 /// Is a notes merge stopped mid-flight?
153 ///
154 /// Git records one as `NOTES_MERGE_REF` in the gitdir. Checked so
155 /// the menu can offer *commit* / *abort* only when they mean
156 /// something — the same gating `magit-notes` does, and the same
157 /// reason `B` bisect is gated (MG.21g): outside a merge git errors
158 /// on both, so ungated rows would look actionable and fail.
159 pub fn merge_in_progress(gitdir: &Path) -> bool {
160 gitdir.join("NOTES_MERGE_REF").exists()
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 /// The strategy names are git's, and a typo in one is a merge that
169 /// resolves differently from what was asked — so they are pinned
170 /// rather than trusted to the `Display` of an enum.
171 #[test]
172 fn strategy_names_are_gits_own() {
173 for (s, name) in [
174 (NoteMergeStrategy::Manual, "manual"),
175 (NoteMergeStrategy::Ours, "ours"),
176 (NoteMergeStrategy::Theirs, "theirs"),
177 (NoteMergeStrategy::Union, "union"),
178 (NoteMergeStrategy::CatSortUniq, "cat_sort_uniq"),
179 ] {
180 assert_eq!(s.as_str(), name);
181 assert_eq!(NoteMergeStrategy::parse(name), Some(s), "round-trips");
182 }
183 }
184
185 /// An unknown strategy is refused, not silently defaulted. Falling
186 /// back to `manual` would resolve the merge a different way than the
187 /// user asked and give no sign it had.
188 #[test]
189 fn an_unknown_strategy_is_refused_rather_than_defaulted() {
190 for bad in ["", " ", "Ours", "cat-sort-uniq", "mine"] {
191 assert_eq!(
192 NoteMergeStrategy::parse(bad),
193 None,
194 "{bad:?} must not resolve to a strategy"
195 );
196 }
197 }
198
199 /// A merge is only in progress when git says so. Gating the
200 /// commit/abort rows on this is what keeps them from being rows that
201 /// error when pressed.
202 #[test]
203 fn a_gitdir_with_no_notes_merge_is_not_in_progress() {
204 let dir = tempfile::tempdir().expect("tempdir");
205 assert!(!Note::merge_in_progress(dir.path()));
206 std::fs::write(dir.path().join("NOTES_MERGE_REF"), "ref\n").expect("write");
207 assert!(Note::merge_in_progress(dir.path()));
208 }
209}