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}