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;
20use mkit_core::object::{Object, ObjectType, Tag};
21use mkit_core::refs;
22use mkit_core::serialize;
23use mkit_core::store::ObjectStore;
24
25use crate::clap_shim;
26use crate::editor::spawn_editor;
27use crate::exit;
28use crate::format;
29
30const TAG_EDITMSG_TEMPLATE: &str =
31    "\n# Write a message for tag.\n# Lines starting with '#' are ignored.\n";
32
33#[derive(Debug, Parser)]
34#[command(name = "mkit tag", about = "List, create, or delete tags.")]
35struct TagOpts {
36    /// Delete the named tag instead of creating one.
37    #[arg(short = 'd', long)]
38    delete: bool,
39    /// Create an unsigned annotated tag object.
40    #[arg(short = 'a', long)]
41    annotate: bool,
42    /// Create a signed annotated tag object (implies -a).
43    #[arg(short = 's', long)]
44    sign: bool,
45    /// Tag message. With -a/-s and no -m, `$EDITOR` is launched.
46    #[arg(short = 'm', long)]
47    message: Option<String>,
48    /// Override the tagger Identity for this tag.
49    #[arg(long = "author", value_name = "SPEC")]
50    author_spec: Option<String>,
51    /// Tag name. Omit to list all tags.
52    name: Option<String>,
53    /// Commit-ish to tag. Defaults to HEAD.
54    target: Option<String>,
55}
56
57#[must_use]
58pub fn run(args: &[String]) -> u8 {
59    let opts = match clap_shim::parse::<TagOpts>("mkit tag", args) {
60        Ok(o) => o,
61        Err(code) => return code,
62    };
63    let cwd = match std::env::current_dir() {
64        Ok(p) => p,
65        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
66    };
67    let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
68
69    // -s implies -a (a signed tag is an annotated tag with a signature).
70    let annotated = opts.annotate || opts.sign;
71
72    match (opts.delete, opts.name.as_deref()) {
73        (true, Some(name)) => match refs::delete_tag(&mkit_dir, name) {
74            Ok(()) => exit::OK,
75            Err(e) => emit_err(&format!("delete tag {name}: {e}"), exit::GENERAL_ERROR),
76        },
77        (true, None) => super::usage_error("usage: mkit tag -d <name>"),
78        (false, None) => {
79            if annotated || opts.message.is_some() {
80                return super::usage_error("usage: mkit tag -a|-s <name> [-m <msg>] [<commit>]");
81            }
82            list(&mkit_dir)
83        }
84        (false, Some(name)) => {
85            if annotated {
86                create_annotated(&cwd, &mkit_dir, &opts, name)
87            } else {
88                if opts.message.is_some() {
89                    return super::usage_error(
90                        "the -m flag requires -a or -s (annotated/signed tag)",
91                    );
92                }
93                create_lightweight(&cwd, &mkit_dir, name, opts.target.as_deref())
94            }
95        }
96    }
97}
98
99fn list(mkit_dir: &std::path::Path) -> u8 {
100    let tags = match refs::list_tags(mkit_dir) {
101        Ok(t) => t,
102        Err(e) => return emit_err(&format!("list tags: {e}"), exit::GENERAL_ERROR),
103    };
104    // Open the store from the repo root (parent of `.mkit`) so we can
105    // peek at annotated-tag objects. Listing still works if this fails.
106    let store = mkit_dir
107        .parent()
108        .and_then(|root| ObjectStore::open(root).ok());
109    let mut stdout = std::io::stdout().lock();
110    for t in tags {
111        let short = t
112            .hash
113            .map(|h| format::short_hash(&h, 8))
114            .unwrap_or_default();
115        // Surface annotated-tag metadata: if the ref points at a Tag
116        // object, mark it and show whether it carries a signature.
117        let annotation = t.hash.and_then(|h| {
118            let store = store.as_ref()?;
119            match store.read_object(&h) {
120                Ok(Object::Tag(tag)) => Some(if tag.signature == [0u8; 64] {
121                    "\tannotated".to_string()
122                } else {
123                    "\tsigned".to_string()
124                }),
125                _ => None,
126            }
127        });
128        let _ = writeln!(
129            stdout,
130            "{} {short}{}",
131            t.name,
132            annotation.unwrap_or_default()
133        );
134    }
135    exit::OK
136}
137
138/// Resolve the target hash for `target_spec` (or HEAD when `None`).
139fn resolve_target(
140    store: &ObjectStore,
141    mkit_dir: &std::path::Path,
142    target_spec: Option<&str>,
143) -> Result<mkit_core::hash::Hash, (String, u8)> {
144    match target_spec {
145        Some(spec) => super::revspec::resolve_revision(store, mkit_dir, spec)
146            .map_err(|e| (format!("{e}"), exit::DATAERR)),
147        None => match refs::resolve_head(mkit_dir) {
148            Ok(Some(h)) => Ok(h),
149            _ => Err(("no HEAD commit to tag".to_string(), exit::GENERAL_ERROR)),
150        },
151    }
152}
153
154fn create_lightweight(
155    cwd: &std::path::Path,
156    mkit_dir: &std::path::Path,
157    name: &str,
158    target_spec: Option<&str>,
159) -> u8 {
160    let store = match ObjectStore::open(cwd) {
161        Ok(s) => s,
162        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
163    };
164    let h = match resolve_target(&store, mkit_dir, target_spec) {
165        Ok(h) => h,
166        Err((m, c)) => return emit_err(&m, c),
167    };
168    // A lightweight tag publishes a root ref to an existing object, so it is
169    // also a gc root publisher: hold the lock and re-verify the target under
170    // it before writing the ref, so a concurrent `gc --grace-secs 0` can't
171    // prune the (possibly unreachable) target between resolve and publish
172    // (#267).
173    let _lock = match super::acquire_worktree_lock(cwd) {
174        Ok(l) => l,
175        Err(code) => return code,
176    };
177    if !store.contains(&h) {
178        return emit_err(
179            &format!(
180                "tag target {} no longer exists (pruned concurrently?); aborting",
181                format::short_hash(&h, 8)
182            ),
183            exit::GENERAL_ERROR,
184        );
185    }
186    // `Missing` (issue #206) refuses to silently overwrite an existing
187    // tag of the same name.
188    match refs::update_tag(mkit_dir, name, refs::RefWriteCondition::Missing, &h) {
189        Ok(()) => exit::OK,
190        Err(refs::RefError::Conflict(_)) => {
191            emit_err(&format!("tag '{name}' already exists"), exit::CANTCREAT)
192        }
193        Err(e) => emit_err(&format!("write tag {name}: {e}"), exit::CANTCREAT),
194    }
195}
196
197fn create_annotated(
198    cwd: &std::path::Path,
199    mkit_dir: &std::path::Path,
200    opts: &TagOpts,
201    name: &str,
202) -> u8 {
203    let store = match ObjectStore::open(cwd) {
204        Ok(s) => s,
205        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
206    };
207    let cfg = match crate::config::read_or_default(cwd) {
208        Ok(c) => c,
209        Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
210    };
211
212    // Resolve the target object and remember its type for the tag.
213    let target = match resolve_target(&store, mkit_dir, opts.target.as_deref()) {
214        Ok(h) => h,
215        Err((m, c)) => return emit_err(&m, c),
216    };
217    let target_type = match store.read_object(&target) {
218        Ok(o) => o.object_type(),
219        Err(e) => return emit_err(&format!("read target: {e}"), exit::NOINPUT),
220    };
221
222    // ---- Resolve / prompt for message. ----
223    let msg = match &opts.message {
224        Some(m) => m.clone(),
225        None => match spawn_editor(TAG_EDITMSG_TEMPLATE) {
226            Ok(m) if !m.is_empty() => m,
227            Ok(_) => return emit_err("empty tag message โ€” aborting", exit::USAGE),
228            Err(e) => return emit_err(&format!("editor: {e}"), exit::GENERAL_ERROR),
229        },
230    };
231
232    // ---- Load signer (also used to derive the tagger fallback). ----
233    let mut signer = match super::commit::load_commit_signer(cwd, &cfg) {
234        Ok(s) => s,
235        Err((m, c)) => return emit_err(&m, c),
236    };
237    let signer_public = match signer.public_key() {
238        Ok(p) => p,
239        Err((m, c)) => return emit_err(&m, c),
240    };
241    let tagger = match super::commit::resolve_author(
242        opts.author_spec.as_deref(),
243        &cfg.user_identity,
244        &signer_public,
245    ) {
246        Ok(id) => id,
247        Err(e) => return emit_err(&format!("tagger: {e}"), exit::CONFIG_ERROR),
248    };
249
250    let timestamp = SystemTime::now()
251        .duration_since(UNIX_EPOCH)
252        .map_or(0, |d| d.as_secs());
253
254    let mut tag = Tag {
255        target,
256        target_type,
257        name: name.as_bytes().to_vec(),
258        tagger,
259        signer: signer_public,
260        message: msg.as_bytes().to_vec(),
261        timestamp,
262        signature: [0u8; 64],
263    };
264
265    if opts.sign {
266        match signer.sign_tag(&tag) {
267            Ok(sig) => tag.signature = sig,
268            Err((m, c)) => return emit_err(&m, c),
269        }
270    }
271    // Annotated-but-unsigned tags keep the zero signature.
272
273    // Hold the repo lock across the tag-object write + ref publish so a
274    // concurrent `gc --grace-secs 0` can't prune the just-written tag object
275    // before its ref makes it reachable (#267). The repo was validated above
276    // (store open), so a non-repo already reported cleanly โ€” not as a lock
277    // error. Acquired here (after the editor/signing) to keep the hold tight.
278    let _lock = match super::acquire_worktree_lock(cwd) {
279        Ok(l) => l,
280        Err(code) => return code,
281    };
282    // The target was resolved before the lock; re-verify it still exists now
283    // that gc can't run, so we never publish a tag pointing at an object a
284    // concurrent `gc --grace-secs 0` pruned in the meantime (#267).
285    if !store.contains(&target) {
286        return emit_err(
287            &format!(
288                "tag target {} no longer exists (pruned concurrently?); aborting",
289                format::short_hash(&target, 8)
290            ),
291            exit::GENERAL_ERROR,
292        );
293    }
294
295    let bytes = match serialize::serialize(&Object::Tag(tag)) {
296        Ok(b) => b,
297        Err(e) => return emit_err(&format!("serialize tag: {e}"), exit::DATAERR),
298    };
299    let tag_hash = match store.write(&bytes) {
300        Ok(h) => h,
301        Err(e) => return emit_err(&format!("store tag: {e}"), exit::CANTCREAT),
302    };
303    match refs::update_tag(mkit_dir, name, refs::RefWriteCondition::Missing, &tag_hash) {
304        Ok(()) => {}
305        Err(refs::RefError::Conflict(_)) => {
306            return emit_err(&format!("tag '{name}' already exists"), exit::CANTCREAT);
307        }
308        Err(e) => return emit_err(&format!("write tag {name}: {e}"), exit::CANTCREAT),
309    }
310    let mut stderr = std::io::stderr().lock();
311    let kind = if opts.sign { "signed" } else { "annotated" };
312    let _ = writeln!(
313        stderr,
314        "created {kind} tag {name} -> {} ({})",
315        format::short_hash(&target, 8),
316        ObjectType::name(target_type),
317    );
318    exit::OK
319}
320
321fn emit_err(msg: &str, code: u8) -> u8 {
322    let mut stderr = std::io::stderr().lock();
323    let _ = writeln!(stderr, "error: {msg}");
324    code
325}