Skip to main content

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}