Skip to main content

git_spawn/command/
notes.rs

1//! `git notes` — add or inspect object notes stored in a `refs/notes/*` namespace.
2//!
3//! Notes attach mutable metadata to any git object (commit, blob, tree, tag)
4//! without rewriting it. Each note lives in a ref namespace — the default is
5//! `refs/notes/commits`, but any namespace works. This wrapper keeps the
6//! namespace raw: pass whatever [`ref_namespace`](NotesCommand::ref_namespace)
7//! value you want (`refs/notes/embeddings`, a short `build`, ...) and git-spawn
8//! forwards it verbatim via `--ref` without prepending `refs/notes/`.
9//!
10//! Note payloads can be binary or large (the underlying object is just a blob).
11//! Prefer [`message_file`](NotesCommand::message_file) over
12//! [`message`](NotesCommand::message) for such payloads: it reads the bytes from
13//! a file (`-F`), dodging argument-length limits, and pairs naturally with
14//! [`no_stripspace`](NotesCommand::no_stripspace) for byte-exact round-trips.
15//!
16//! # Sharing notes across repositories
17//!
18//! Notes refs are not fetched or pushed by default. Because
19//! [`PushCommand`](crate::command::push::PushCommand) and
20//! [`FetchCommand`](crate::command::fetch::FetchCommand) accept arbitrary
21//! refspecs, moving a notes namespace between repositories needs no dedicated
22//! method — just name the notes ref:
23//!
24//! ```no_run
25//! # async fn ex() -> git_spawn::Result<()> {
26//! use git_spawn::{GitCommand, Repository};
27//! use git_spawn::command::notes::NotesCommand;
28//!
29//! let repo = Repository::open("/repo")?;
30//!
31//! // Attach a note to HEAD in a custom namespace.
32//! repo.notes(NotesCommand::add())
33//!     .ref_namespace("refs/notes/test")
34//!     .object("HEAD")
35//!     .message("reviewed")
36//!     .execute()
37//!     .await?;
38//!
39//! // Publish the namespace to a remote, then fetch it back elsewhere.
40//! repo.push()
41//!     .remote("origin")
42//!     .refspec("refs/notes/test:refs/notes/test")
43//!     .execute()
44//!     .await?;
45//! repo.fetch()
46//!     .remote("origin")
47//!     .refspec("refs/notes/test:refs/notes/test")
48//!     .execute()
49//!     .await?;
50//! # Ok(())
51//! # }
52//! ```
53
54use crate::command::{CommandExecutor, CommandOutput, GitCommand};
55use crate::error::Result;
56use async_trait::async_trait;
57use std::path::PathBuf;
58
59/// Actions supported by `git notes`.
60#[derive(Debug, Clone)]
61pub enum NotesAction {
62    /// `git notes add [-f] [--allow-empty] [-m <msg> | -F <file>] [<object>]`.
63    Add {
64        /// Target object (defaults to `HEAD` when `None`).
65        object: Option<String>,
66        /// `-m <msg>` note contents as a string.
67        message: Option<String>,
68        /// `-F <file>` note contents read from a file.
69        message_file: Option<PathBuf>,
70        /// `-f` replace an existing note.
71        force: bool,
72        /// `--allow-empty` store a note even if it is empty.
73        allow_empty: bool,
74        /// `--no-stripspace` keep the payload byte-for-byte.
75        no_stripspace: bool,
76    },
77    /// `git notes append [--allow-empty] [-m <msg> | -F <file>] [<object>]`.
78    Append {
79        /// Target object (defaults to `HEAD` when `None`).
80        object: Option<String>,
81        /// `-m <msg>` text to append.
82        message: Option<String>,
83        /// `-F <file>` payload to append, read from a file.
84        message_file: Option<PathBuf>,
85        /// `--allow-empty` store a note even if it is empty.
86        allow_empty: bool,
87        /// `--no-stripspace` keep the payload byte-for-byte.
88        no_stripspace: bool,
89    },
90    /// `git notes copy [-f] <from-object> <to-object>`.
91    Copy {
92        /// Object to copy the note from.
93        from: String,
94        /// Object to copy the note to.
95        to: String,
96        /// `-f` overwrite an existing note on the target.
97        force: bool,
98    },
99    /// `git notes show [<object>]`.
100    ///
101    /// Exits non-zero when no note exists for the object — surfaced as a clean
102    /// [`Error::CommandFailed`](crate::error::Error::CommandFailed), never
103    /// swallowed.
104    Show {
105        /// Target object (defaults to `HEAD` when `None`).
106        object: Option<String>,
107    },
108    /// `git notes list [<object>]`.
109    List {
110        /// Restrict to a single object (otherwise lists every note).
111        object: Option<String>,
112    },
113    /// `git notes remove [--ignore-missing] [<object>]`.
114    Remove {
115        /// Target object (defaults to `HEAD` when `None`).
116        object: Option<String>,
117        /// `--ignore-missing` exit zero when there is no note to remove.
118        ignore_missing: bool,
119    },
120    /// `git notes prune [-n] [-v]` — drop notes for non-existent objects.
121    Prune {
122        /// `-n` dry run: report without removing.
123        dry_run: bool,
124        /// `-v` report pruned objects.
125        verbose: bool,
126    },
127}
128
129/// Builder for `git notes`.
130#[derive(Debug, Clone)]
131pub struct NotesCommand {
132    /// Shared executor.
133    pub executor: CommandExecutor,
134    /// `--ref <namespace>` — the notes ref to operate on (raw, not prefixed).
135    pub ref_namespace: Option<String>,
136    /// Action.
137    pub action: NotesAction,
138}
139
140impl NotesCommand {
141    /// `notes add`.
142    #[must_use]
143    pub fn add() -> Self {
144        Self {
145            executor: CommandExecutor::default(),
146            ref_namespace: None,
147            action: NotesAction::Add {
148                object: None,
149                message: None,
150                message_file: None,
151                force: false,
152                allow_empty: false,
153                no_stripspace: false,
154            },
155        }
156    }
157
158    /// `notes append`.
159    #[must_use]
160    pub fn append() -> Self {
161        Self {
162            executor: CommandExecutor::default(),
163            ref_namespace: None,
164            action: NotesAction::Append {
165                object: None,
166                message: None,
167                message_file: None,
168                allow_empty: false,
169                no_stripspace: false,
170            },
171        }
172    }
173
174    /// `notes copy <from> <to>`.
175    pub fn copy(from: impl Into<String>, to: impl Into<String>) -> Self {
176        Self {
177            executor: CommandExecutor::default(),
178            ref_namespace: None,
179            action: NotesAction::Copy {
180                from: from.into(),
181                to: to.into(),
182                force: false,
183            },
184        }
185    }
186
187    /// `notes show`.
188    #[must_use]
189    pub fn show() -> Self {
190        Self {
191            executor: CommandExecutor::default(),
192            ref_namespace: None,
193            action: NotesAction::Show { object: None },
194        }
195    }
196
197    /// `notes list`.
198    #[must_use]
199    pub fn list() -> Self {
200        Self {
201            executor: CommandExecutor::default(),
202            ref_namespace: None,
203            action: NotesAction::List { object: None },
204        }
205    }
206
207    /// `notes remove`.
208    #[must_use]
209    pub fn remove() -> Self {
210        Self {
211            executor: CommandExecutor::default(),
212            ref_namespace: None,
213            action: NotesAction::Remove {
214                object: None,
215                ignore_missing: false,
216            },
217        }
218    }
219
220    /// `notes prune`.
221    #[must_use]
222    pub fn prune() -> Self {
223        Self {
224            executor: CommandExecutor::default(),
225            ref_namespace: None,
226            action: NotesAction::Prune {
227                dry_run: false,
228                verbose: false,
229            },
230        }
231    }
232
233    /// Operate on the given notes ref namespace, emitted as `--ref <ns>`.
234    ///
235    /// The value is forwarded verbatim — git-spawn does not prepend
236    /// `refs/notes/`. Pass a full ref (`refs/notes/embeddings`) or a short name
237    /// (`build`); git resolves short names under `refs/notes/` itself.
238    pub fn ref_namespace(&mut self, ns: impl Into<String>) -> &mut Self {
239        self.ref_namespace = Some(ns.into());
240        self
241    }
242
243    /// Set the target object (for `add`, `append`, `show`, `list`, `remove`).
244    pub fn object(&mut self, o: impl Into<String>) -> &mut Self {
245        let o = o.into();
246        match &mut self.action {
247            NotesAction::Add { object, .. }
248            | NotesAction::Append { object, .. }
249            | NotesAction::Show { object, .. }
250            | NotesAction::List { object, .. }
251            | NotesAction::Remove { object, .. } => *object = Some(o),
252            NotesAction::Copy { .. } | NotesAction::Prune { .. } => {}
253        }
254        self
255    }
256
257    /// Note payload as an inline string, emitted as `-m <msg>` (for `add` /
258    /// `append`).
259    ///
260    /// For binary or large payloads prefer [`message_file`](Self::message_file)
261    /// to avoid argument-length limits.
262    pub fn message(&mut self, m: impl Into<String>) -> &mut Self {
263        let m = m.into();
264        match &mut self.action {
265            NotesAction::Add { message, .. } | NotesAction::Append { message, .. } => {
266                *message = Some(m);
267            }
268            _ => {}
269        }
270        self
271    }
272
273    /// Note payload read from a file, emitted as `-F <path>` (for `add` /
274    /// `append`). The preferred form for binary or multi-kilobyte payloads.
275    pub fn message_file(&mut self, p: impl Into<PathBuf>) -> &mut Self {
276        let p = p.into();
277        match &mut self.action {
278            NotesAction::Add { message_file, .. } | NotesAction::Append { message_file, .. } => {
279                *message_file = Some(p);
280            }
281            _ => {}
282        }
283        self
284    }
285
286    /// `-f` — replace an existing note (for `add` / `copy`).
287    pub fn force(&mut self) -> &mut Self {
288        match &mut self.action {
289            NotesAction::Add { force, .. } | NotesAction::Copy { force, .. } => *force = true,
290            _ => {}
291        }
292        self
293    }
294
295    /// `--allow-empty` — store a note even when the payload is empty (for `add`
296    /// / `append`).
297    pub fn allow_empty(&mut self) -> &mut Self {
298        match &mut self.action {
299            NotesAction::Add { allow_empty, .. } | NotesAction::Append { allow_empty, .. } => {
300                *allow_empty = true;
301            }
302            _ => {}
303        }
304        self
305    }
306
307    /// `--no-stripspace` — preserve the payload byte-for-byte instead of
308    /// trimming whitespace (for `add` / `append`). Required for exact binary
309    /// round-trips.
310    pub fn no_stripspace(&mut self) -> &mut Self {
311        match &mut self.action {
312            NotesAction::Add { no_stripspace, .. } | NotesAction::Append { no_stripspace, .. } => {
313                *no_stripspace = true
314            }
315            _ => {}
316        }
317        self
318    }
319
320    /// `--ignore-missing` — exit zero when there is no note to remove (for
321    /// `remove`).
322    pub fn ignore_missing(&mut self) -> &mut Self {
323        if let NotesAction::Remove { ignore_missing, .. } = &mut self.action {
324            *ignore_missing = true;
325        }
326        self
327    }
328
329    /// `-n` dry run (for `prune`).
330    pub fn dry_run(&mut self) -> &mut Self {
331        if let NotesAction::Prune { dry_run, .. } = &mut self.action {
332            *dry_run = true;
333        }
334        self
335    }
336
337    /// `-v` verbose (for `prune`).
338    pub fn verbose(&mut self) -> &mut Self {
339        if let NotesAction::Prune { verbose, .. } = &mut self.action {
340            *verbose = true;
341        }
342        self
343    }
344}
345
346#[async_trait]
347impl GitCommand for NotesCommand {
348    type Output = CommandOutput;
349    fn get_executor(&self) -> &CommandExecutor {
350        &self.executor
351    }
352    fn get_executor_mut(&mut self) -> &mut CommandExecutor {
353        &mut self.executor
354    }
355    fn build_command_args(&self) -> Vec<String> {
356        let mut args = vec!["notes".to_string()];
357        if let Some(ns) = &self.ref_namespace {
358            args.push("--ref".into());
359            args.push(ns.clone());
360        }
361        match &self.action {
362            NotesAction::Add {
363                object,
364                message,
365                message_file,
366                force,
367                allow_empty,
368                no_stripspace,
369            } => {
370                args.push("add".into());
371                if *force {
372                    args.push("-f".into());
373                }
374                if *allow_empty {
375                    args.push("--allow-empty".into());
376                }
377                if *no_stripspace {
378                    args.push("--no-stripspace".into());
379                }
380                if let Some(m) = message {
381                    args.push("-m".into());
382                    args.push(m.clone());
383                }
384                if let Some(f) = message_file {
385                    args.push("-F".into());
386                    args.push(f.display().to_string());
387                }
388                if let Some(o) = object {
389                    args.push(o.clone());
390                }
391            }
392            NotesAction::Append {
393                object,
394                message,
395                message_file,
396                allow_empty,
397                no_stripspace,
398            } => {
399                args.push("append".into());
400                if *allow_empty {
401                    args.push("--allow-empty".into());
402                }
403                if *no_stripspace {
404                    args.push("--no-stripspace".into());
405                }
406                if let Some(m) = message {
407                    args.push("-m".into());
408                    args.push(m.clone());
409                }
410                if let Some(f) = message_file {
411                    args.push("-F".into());
412                    args.push(f.display().to_string());
413                }
414                if let Some(o) = object {
415                    args.push(o.clone());
416                }
417            }
418            NotesAction::Copy { from, to, force } => {
419                args.push("copy".into());
420                if *force {
421                    args.push("-f".into());
422                }
423                args.push(from.clone());
424                args.push(to.clone());
425            }
426            NotesAction::Show { object } => {
427                args.push("show".into());
428                if let Some(o) = object {
429                    args.push(o.clone());
430                }
431            }
432            NotesAction::List { object } => {
433                args.push("list".into());
434                if let Some(o) = object {
435                    args.push(o.clone());
436                }
437            }
438            NotesAction::Remove {
439                object,
440                ignore_missing,
441            } => {
442                args.push("remove".into());
443                if *ignore_missing {
444                    args.push("--ignore-missing".into());
445                }
446                if let Some(o) = object {
447                    args.push(o.clone());
448                }
449            }
450            NotesAction::Prune { dry_run, verbose } => {
451                args.push("prune".into());
452                if *dry_run {
453                    args.push("-n".into());
454                }
455                if *verbose {
456                    args.push("-v".into());
457                }
458            }
459        }
460        args
461    }
462    async fn execute(&self) -> Result<CommandOutput> {
463        self.execute_raw().await
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn add_with_namespace_and_message() {
473        let mut c = NotesCommand::add();
474        c.ref_namespace("refs/notes/test")
475            .object("HEAD")
476            .message("hi")
477            .force();
478        assert_eq!(
479            c.build_command_args(),
480            vec![
481                "notes",
482                "--ref",
483                "refs/notes/test",
484                "add",
485                "-f",
486                "-m",
487                "hi",
488                "HEAD"
489            ]
490        );
491    }
492
493    #[test]
494    fn add_with_file_and_no_stripspace() {
495        let mut c = NotesCommand::add();
496        c.object("HEAD")
497            .message_file("/tmp/payload.bin")
498            .no_stripspace();
499        assert_eq!(
500            c.build_command_args(),
501            vec![
502                "notes",
503                "add",
504                "--no-stripspace",
505                "-F",
506                "/tmp/payload.bin",
507                "HEAD"
508            ]
509        );
510    }
511
512    #[test]
513    fn append_payload() {
514        let mut c = NotesCommand::append();
515        c.object("HEAD").message("more");
516        assert_eq!(
517            c.build_command_args(),
518            vec!["notes", "append", "-m", "more", "HEAD"]
519        );
520    }
521
522    #[test]
523    fn copy_force() {
524        let mut c = NotesCommand::copy("a", "b");
525        c.force();
526        assert_eq!(
527            c.build_command_args(),
528            vec!["notes", "copy", "-f", "a", "b"]
529        );
530    }
531
532    #[test]
533    fn show_list_remove_prune() {
534        let mut s = NotesCommand::show();
535        s.ref_namespace("build").object("HEAD");
536        assert_eq!(
537            s.build_command_args(),
538            vec!["notes", "--ref", "build", "show", "HEAD"]
539        );
540
541        let mut l = NotesCommand::list();
542        l.object("HEAD");
543        assert_eq!(l.build_command_args(), vec!["notes", "list", "HEAD"]);
544
545        let mut r = NotesCommand::remove();
546        r.object("HEAD").ignore_missing();
547        assert_eq!(
548            r.build_command_args(),
549            vec!["notes", "remove", "--ignore-missing", "HEAD"]
550        );
551
552        let mut p = NotesCommand::prune();
553        p.dry_run().verbose();
554        assert_eq!(p.build_command_args(), vec!["notes", "prune", "-n", "-v"]);
555    }
556}