Skip to main content

mkit_cli/commands/
tag.rs

1//! `mkit tag` โ€” list / create / delete tags.
2//!
3//! Three creation modes:
4//!
5//! * **Lightweight** (`mkit tag <name> [<commit>]`): writes a tag ref
6//!   pointing straight at the target commit hash. No tag object.
7//! * **Annotated** (`mkit tag -a <name> [-m <msg>] [<commit>]`): builds
8//!   a [`Tag`] object (target, tagger identity, message, timestamp) and
9//!   points the tag ref at the tag-object hash. Unsigned (zero
10//!   signature).
11//! * **Signed** (`mkit tag -s <name> [-m <msg>] [<commit>]`): an
12//!   annotated tag whose 64-byte field is an Ed25519 signature over the
13//!   canonical tag signing bytes under the distinct `mkit.tag\0` domain
14//!   (SPEC-SIGNING ยง4a). Verify with `mkit verify <name>`.
15
16use std::io::Write;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use clap::{Parser, ValueEnum};
20use mkit_core::layout::RepoLayout;
21use mkit_core::object::{Object, ObjectType, Tag};
22use mkit_core::refs;
23use mkit_core::serialize;
24use mkit_core::store::ObjectStore;
25
26use crate::clap_shim;
27use crate::editor::spawn_editor;
28use crate::exit;
29use crate::format::{self, JsonObject};
30
31const TAG_EDITMSG_TEMPLATE: &str =
32    "\n# Write a message for tag.\n# Lines starting with '#' are ignored.\n";
33
34#[derive(Debug, Clone, Copy, ValueEnum)]
35enum TagFormat {
36    Default,
37    Json,
38}
39
40#[derive(Debug, Parser)]
41#[command(name = "mkit tag", about = "List, create, or delete tags.")]
42#[allow(clippy::struct_excessive_bools)] // clap option flags, not a state machine
43struct TagOpts {
44    /// Delete the named tag instead of creating one.
45    #[arg(short = 'd', long)]
46    delete: bool,
47    /// List tags, optionally filtered by a shell glob pattern
48    /// (`mkit tag -l 'v*'`).
49    #[arg(short = 'l', long = "list")]
50    list: bool,
51    /// Create an unsigned annotated tag object.
52    #[arg(short = 'a', long)]
53    annotate: bool,
54    /// Create a signed annotated tag object (implies -a).
55    #[arg(short = 's', long)]
56    sign: bool,
57    /// Tag message. With -a/-s and no -m, `$EDITOR` is launched.
58    #[arg(short = 'm', long)]
59    message: Option<String>,
60    /// Override the tagger Identity for this tag.
61    #[arg(long = "author", value_name = "SPEC")]
62    author_spec: Option<String>,
63    /// Output format. On the list form, JSONL with keys `name`, `hash`,
64    /// `annotated`, `signed`; on create/delete, one outcome object.
65    #[arg(long, value_enum, default_value = "default")]
66    format: TagFormat,
67    /// Tag name. Omit to list all tags.
68    name: Option<String>,
69    /// Commit-ish to tag. Defaults to HEAD.
70    target: Option<String>,
71}
72
73/// `error(msg, code)` plus, when `json` is set, a `{"ok":false,...}`
74/// line on stdout.
75fn emit_err_json(msg: &str, code: u8, json: bool) -> u8 {
76    if json {
77        let mut obj = JsonObject::new();
78        obj.field_bool("ok", false).field_str("error", msg);
79        let mut stdout = std::io::stdout().lock();
80        let _ = writeln!(stdout, "{}", obj.finish());
81    }
82    emit_err(msg, code)
83}
84
85#[must_use]
86pub fn run(args: &[String]) -> u8 {
87    let opts = match clap_shim::parse::<TagOpts>("mkit tag", args) {
88        Ok(o) => o,
89        Err(code) => return code,
90    };
91    let json = matches!(opts.format, TagFormat::Json);
92    let cwd = match std::env::current_dir() {
93        Ok(p) => p,
94        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
95    };
96    let layout = match super::resolve_layout(&cwd) {
97        Ok(layout) => layout,
98        Err(code) => return code,
99    };
100
101    // -s implies -a (a signed tag is an annotated tag with a signature).
102    let annotated = opts.annotate || opts.sign;
103
104    // `-l`/`--list` forces list mode, with the positional treated as a
105    // glob filter (like `git tag -l '<pattern>'`). `-l` with `-d` is an
106    // error, like git (the modes are mutually exclusive).
107    if opts.list {
108        if opts.delete {
109            return super::usage_error("mkit tag: -l and -d are mutually exclusive");
110        }
111        return list(&layout, opts.name.as_deref(), json);
112    }
113
114    match (opts.delete, opts.name.as_deref()) {
115        (true, Some(name)) => {
116            let was = refs::read_tag(&layout, name).ok().flatten();
117            match refs::delete_tag(&layout, name) {
118                Ok(()) => {
119                    let mut stderr = std::io::stderr().lock();
120                    match was {
121                        Some(h) => {
122                            let _ = writeln!(
123                                stderr,
124                                "Deleted tag '{name}' (was {})",
125                                format::short_hash(&h, format::SUMMARY_ABBREV)
126                            );
127                        }
128                        None => {
129                            let _ = writeln!(stderr, "Deleted tag '{name}'");
130                        }
131                    }
132                    drop(stderr);
133                    if json {
134                        let mut obj = JsonObject::new();
135                        obj.field_bool("ok", true)
136                            .field_str("kind", "deleted")
137                            .field_str("name", name)
138                            .field_opt_hash("hash", was.as_ref());
139                        let mut stdout = std::io::stdout().lock();
140                        let _ = writeln!(stdout, "{}", obj.finish());
141                    }
142                    exit::OK
143                }
144                Err(e) => emit_err_json(
145                    &format!("delete tag {name}: {e}"),
146                    exit::GENERAL_ERROR,
147                    json,
148                ),
149            }
150        }
151        (true, None) => super::usage_error("usage: mkit tag -d <name>"),
152        (false, None) => {
153            if annotated || opts.message.is_some() {
154                return super::usage_error("usage: mkit tag -a|-s <name> [-m <msg>] [<commit>]");
155            }
156            list(&layout, None, json)
157        }
158        (false, Some(name)) => {
159            if annotated {
160                create_annotated(&layout, &opts, name, json)
161            } else {
162                if opts.message.is_some() {
163                    return super::usage_error(
164                        "the -m flag requires -a or -s (annotated/signed tag)",
165                    );
166                }
167                create_lightweight(&layout, name, opts.target.as_deref(), json)
168            }
169        }
170    }
171}
172
173fn list(layout: &RepoLayout, pattern: Option<&str>, json: bool) -> u8 {
174    let mut tags = match refs::list_tags(layout) {
175        Ok(t) => t,
176        Err(e) => return emit_err_json(&format!("list tags: {e}"), exit::GENERAL_ERROR, json),
177    };
178    if let Some(pat) = pattern {
179        tags.retain(|t| super::branch::glob_match(pat, &t.name));
180    }
181    // Open the store so we can peek at annotated-tag objects. Listing
182    // still works if this fails.
183    let store = ObjectStore::open(layout).ok();
184    let mut stdout = std::io::stdout().lock();
185    for t in tags {
186        let annotation = t.hash.and_then(|h| {
187            let store = store.as_ref()?;
188            match store.read_object(&h) {
189                Ok(Object::Tag(tag)) => Some(tag.signature != [0u8; 64]),
190                _ => None,
191            }
192        });
193        if json {
194            let mut obj = JsonObject::new();
195            obj.field_str("name", &t.name)
196                .field_opt_hash("hash", t.hash.as_ref())
197                .field_bool("annotated", annotation.is_some())
198                .field_bool("signed", annotation.unwrap_or(false));
199            let _ = writeln!(stdout, "{}", obj.finish());
200            continue;
201        }
202        let short = t
203            .hash
204            .map(|h| format::short_hash(&h, 8))
205            .unwrap_or_default();
206        let suffix = match annotation {
207            Some(true) => "\tsigned",
208            Some(false) => "\tannotated",
209            None => "",
210        };
211        let _ = writeln!(stdout, "{} {short}{suffix}", t.name);
212    }
213    exit::OK
214}
215
216/// Resolve the target hash for `target_spec` (or HEAD when `None`).
217fn resolve_target(
218    store: &ObjectStore,
219    layout: &RepoLayout,
220    target_spec: Option<&str>,
221) -> Result<mkit_core::hash::Hash, (String, u8)> {
222    match target_spec {
223        Some(spec) => super::revspec::resolve_revision(store, layout, spec)
224            .map_err(|e| (format!("{e}"), exit::DATAERR)),
225        None => match refs::resolve_head(layout) {
226            Ok(Some(h)) => Ok(h),
227            _ => Err(("no HEAD commit to tag".to_string(), exit::GENERAL_ERROR)),
228        },
229    }
230}
231
232fn create_lightweight(
233    layout: &RepoLayout,
234    name: &str,
235    target_spec: Option<&str>,
236    json: bool,
237) -> u8 {
238    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
239    let store = match ObjectStore::open(layout) {
240        Ok(s) => s,
241        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
242    };
243    let h = match resolve_target(&store, layout, target_spec) {
244        Ok(h) => h,
245        Err((m, c)) => return emit_err(&m, c),
246    };
247    // A lightweight tag publishes a root ref to an existing object, so it is
248    // also a gc root publisher: hold the lock and re-verify the target under
249    // it before writing the ref, so a concurrent `gc --grace-secs 0` can't
250    // prune the (possibly unreachable) target between resolve and publish
251    // (#267).
252    let _lock = match super::acquire_worktree_lock(layout) {
253        Ok(l) => l,
254        Err(code) => return code,
255    };
256    if !store.contains(&h) {
257        return emit_err(
258            &format!(
259                "tag target {} no longer exists (pruned concurrently?); aborting",
260                format::short_hash(&h, 8)
261            ),
262            exit::GENERAL_ERROR,
263        );
264    }
265    // `Missing` (issue #206) refuses to silently overwrite an existing
266    // tag of the same name.
267    match refs::update_tag(layout, name, refs::RefWriteCondition::Missing, &h) {
268        Ok(()) => {
269            if json {
270                let mut obj = JsonObject::new();
271                obj.field_bool("ok", true)
272                    .field_str("kind", "lightweight")
273                    .field_str("name", name)
274                    .field_hash("target", &h);
275                let mut stdout = std::io::stdout().lock();
276                let _ = writeln!(stdout, "{}", obj.finish());
277            }
278            exit::OK
279        }
280        Err(refs::RefError::Conflict(_)) => {
281            emit_err(&format!("tag '{name}' already exists"), exit::CANTCREAT)
282        }
283        Err(e) => emit_err(&format!("write tag {name}: {e}"), exit::CANTCREAT),
284    }
285}
286
287#[allow(clippy::too_many_lines)] // linear flow: resolve + sign + write + report
288fn create_annotated(layout: &RepoLayout, opts: &TagOpts, name: &str, json: bool) -> u8 {
289    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
290    let store = match ObjectStore::open(layout) {
291        Ok(s) => s,
292        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
293    };
294    let cfg = match crate::config::read_or_default(layout) {
295        Ok(c) => c,
296        Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
297    };
298
299    // Resolve the target object and remember its type for the tag.
300    let target = match resolve_target(&store, layout, opts.target.as_deref()) {
301        Ok(h) => h,
302        Err((m, c)) => return emit_err(&m, c),
303    };
304    let target_type = match store.read_object(&target) {
305        Ok(o) => o.object_type(),
306        Err(e) => return emit_err(&format!("read target: {e}"), exit::NOINPUT),
307    };
308
309    // ---- Resolve / prompt for message. ----
310    let msg = match &opts.message {
311        Some(m) => m.clone(),
312        None => match spawn_editor(TAG_EDITMSG_TEMPLATE) {
313            Ok(m) if !m.is_empty() => m,
314            Ok(_) => return emit_err("empty tag message โ€” aborting", exit::USAGE),
315            Err(e) => return emit_err(&format!("editor: {e}"), exit::GENERAL_ERROR),
316        },
317    };
318
319    // ---- Load signer (also used to derive the tagger fallback). ----
320    let mut signer = match super::commit::load_commit_signer(layout, &cfg) {
321        Ok(s) => s,
322        Err((m, c)) => return emit_err(&m, c),
323    };
324    let signer_public = match signer.public_key() {
325        Ok(p) => p,
326        Err((m, c)) => return emit_err(&m, c),
327    };
328    let tagger = match super::commit::resolve_author(
329        opts.author_spec.as_deref(),
330        &cfg.user_identity,
331        &signer_public,
332    ) {
333        Ok(id) => id,
334        Err(e) => return emit_err(&format!("tagger: {e}"), exit::CONFIG_ERROR),
335    };
336
337    let timestamp = SystemTime::now()
338        .duration_since(UNIX_EPOCH)
339        .map_or(0, |d| d.as_secs());
340
341    let mut tag = Tag {
342        target,
343        target_type,
344        name: name.as_bytes().to_vec(),
345        tagger,
346        signer: signer_public,
347        message: msg.as_bytes().to_vec(),
348        timestamp,
349        signature: [0u8; 64],
350    };
351
352    if opts.sign {
353        match signer.sign_tag(&tag) {
354            Ok(sig) => tag.signature = sig,
355            Err((m, c)) => return emit_err(&m, c),
356        }
357    }
358    // Annotated-but-unsigned tags keep the zero signature.
359
360    // Hold the repo lock across the tag-object write + ref publish so a
361    // concurrent `gc --grace-secs 0` can't prune the just-written tag object
362    // before its ref makes it reachable (#267). The repo was validated above
363    // (store open), so a non-repo already reported cleanly โ€” not as a lock
364    // error. Acquired here (after the editor/signing) to keep the hold tight.
365    let _lock = match super::acquire_worktree_lock(layout) {
366        Ok(l) => l,
367        Err(code) => return code,
368    };
369    // The target was resolved before the lock; re-verify it still exists now
370    // that gc can't run, so we never publish a tag pointing at an object a
371    // concurrent `gc --grace-secs 0` pruned in the meantime (#267).
372    if !store.contains(&target) {
373        return emit_err(
374            &format!(
375                "tag target {} no longer exists (pruned concurrently?); aborting",
376                format::short_hash(&target, 8)
377            ),
378            exit::GENERAL_ERROR,
379        );
380    }
381
382    let bytes = match serialize::serialize(&Object::Tag(tag)) {
383        Ok(b) => b,
384        Err(e) => return emit_err(&format!("serialize tag: {e}"), exit::DATAERR),
385    };
386    let tag_hash = match store.write(&bytes) {
387        Ok(h) => h,
388        Err(e) => return emit_err(&format!("store tag: {e}"), exit::CANTCREAT),
389    };
390    match refs::update_tag(layout, name, refs::RefWriteCondition::Missing, &tag_hash) {
391        Ok(()) => {}
392        Err(refs::RefError::Conflict(_)) => {
393            return emit_err(&format!("tag '{name}' already exists"), exit::CANTCREAT);
394        }
395        Err(e) => return emit_err(&format!("write tag {name}: {e}"), exit::CANTCREAT),
396    }
397    let mut stderr = std::io::stderr().lock();
398    let kind = if opts.sign { "signed" } else { "annotated" };
399    let _ = writeln!(
400        stderr,
401        "created {kind} tag {name} -> {} ({})",
402        format::short_hash(&target, 8),
403        ObjectType::name(target_type),
404    );
405    drop(stderr);
406    if json {
407        let mut obj = JsonObject::new();
408        obj.field_bool("ok", true)
409            .field_str("kind", kind)
410            .field_str("name", name)
411            .field_hash("hash", &tag_hash)
412            .field_hash("target", &target)
413            .field_str("target_type", ObjectType::name(target_type));
414        let mut stdout = std::io::stdout().lock();
415        let _ = writeln!(stdout, "{}", obj.finish());
416    }
417    exit::OK
418}
419
420use super::error as emit_err;