Skip to main content

mkit_cli/commands/
update_ref.rs

1//! `mkit update-ref [-d] <ref> [<newvalue> [<oldvalue>]]` — low-level guarded
2//! ref write/delete, like `git update-ref`.
3//!
4//! Supports `refs/heads/<branch>` and `refs/tags/<name>` (the namespaces mkit
5//! manages); other namespaces (`HEAD`, `refs/remotes/…`) are rejected.
6//! `<newvalue>` / `<oldvalue>` resolve through the shared revspec grammar
7//! (ref, full/short hash, `HEAD~n`). Without `<oldvalue>` the write is
8//! unconditional; with one it is a compare-and-swap that fails unless the ref
9//! currently holds that value. In **update** mode an all-zero `<oldvalue>`
10//! means "the ref must not already exist" (git's create-only convention); in
11//! `-d` (delete) mode `<oldvalue>`, if given, must be a concrete value the
12//! ref currently holds (an all-zero value is rejected — you cannot delete a
13//! ref asserted to be absent).
14//!
15//! Safety divergence: `-d` on a branch uses the same guard as `branch -d` —
16//! it refuses to delete the currently checked-out branch (git's plumbing
17//! would, leaving HEAD dangling).
18
19use std::io::Write;
20
21use clap::Parser;
22use mkit_core::hash::Hash;
23use mkit_core::refs::{self, RefWriteCondition};
24use mkit_core::store::ObjectStore;
25
26use super::revspec;
27use crate::clap_shim;
28use crate::exit;
29
30#[derive(Debug, Parser)]
31#[command(
32    name = "mkit update-ref",
33    about = "Create, update, or delete a ref (guarded)."
34)]
35struct UpdateRefOpts {
36    /// Delete the ref instead of updating it.
37    #[arg(short = 'd', long)]
38    delete: bool,
39    /// The ref to write: `refs/heads/<branch>` or `refs/tags/<name>`.
40    name: String,
41    /// New value as a revision (required unless `-d`); for `-d` this slot is
42    /// the optional expected old value.
43    value: Option<String>,
44    /// Expected current value for a compare-and-swap (update mode only).
45    old_value: Option<String>,
46}
47
48/// Which ref namespace a `refs/…` path addresses.
49enum Namespace {
50    Head,
51    Tag,
52}
53
54#[must_use]
55pub fn run(args: &[String]) -> u8 {
56    let opts = match clap_shim::parse::<UpdateRefOpts>("mkit update-ref", args) {
57        Ok(o) => o,
58        Err(code) => return code,
59    };
60    let cwd = match std::env::current_dir() {
61        Ok(p) => p,
62        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
63    };
64    let store = match ObjectStore::open(&cwd) {
65        Ok(s) => s,
66        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
67    };
68    let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
69
70    let Some((ns, name)) = parse_ref(&opts.name) else {
71        return emit_err(
72            &format!(
73                "unsupported ref '{}': update-ref handles refs/heads/<branch> and refs/tags/<name>",
74                opts.name
75            ),
76            exit::USAGE,
77        );
78    };
79
80    // update-ref publishes a gc root (refs/heads/* or refs/tags/*) at an
81    // arbitrary object, so hold the repo lock across resolve + publish: a
82    // concurrent `gc --grace-secs 0` then can't prune a (possibly
83    // unreachable) target between resolving it and writing the ref (#267).
84    // Acquired after repo validation (store open) so a non-repo reported
85    // cleanly above; covers the delete path too for consistency.
86    let _lock = match super::acquire_worktree_lock(&cwd) {
87        Ok(l) => l,
88        Err(code) => return code,
89    };
90
91    if opts.delete {
92        if opts.old_value.is_some() {
93            return super::usage_error("usage: mkit update-ref -d <ref> [<oldvalue>]");
94        }
95        return run_delete(&store, &mkit_dir, &ns, name, opts.value.as_deref());
96    }
97
98    let Some(newspec) = opts.value.as_deref() else {
99        return super::usage_error("usage: mkit update-ref <ref> <newvalue> [<oldvalue>]");
100    };
101    let newhash = match resolve(&store, &mkit_dir, newspec) {
102        Ok(h) => h,
103        Err(msg) => return emit_err(&msg, exit::DATAERR),
104    };
105    // Refuse to publish a ref pointing at an object that is not present
106    // (resolved under the lock, so this also closes the resolve→publish race).
107    if !store.contains(&newhash) {
108        return emit_err(
109            &format!("object '{newspec}' does not exist in the store"),
110            exit::DATAERR,
111        );
112    }
113    let condition = match opts.old_value.as_deref() {
114        None => RefWriteCondition::Any,
115        Some(s) if is_zero(s) => RefWriteCondition::Missing,
116        Some(s) => match resolve(&store, &mkit_dir, s) {
117            Ok(h) => RefWriteCondition::Match(h),
118            Err(msg) => return emit_err(&msg, exit::DATAERR),
119        },
120    };
121    let res = match ns {
122        // Branch moves MUST funnel through the history-recording helper so a
123        // `--features history-mmr` build advances the ref and its journal
124        // together under lock (the CLI ref-write invariant). Tags are not
125        // history-tracked (the journal is keyed per branch).
126        Namespace::Head => super::write_ref_recording_history(&mkit_dir, name, condition, &newhash),
127        Namespace::Tag => refs::update_tag(&mkit_dir, name, condition, &newhash),
128    };
129    match res {
130        Ok(()) => exit::OK,
131        Err(e) => emit_err(
132            &format!("update-ref {}: {e}", opts.name),
133            exit::GENERAL_ERROR,
134        ),
135    }
136}
137
138/// `-d`: delete the ref, optionally verifying its current value first.
139fn run_delete(
140    store: &ObjectStore,
141    mkit_dir: &std::path::Path,
142    ns: &Namespace,
143    name: &str,
144    old_value: Option<&str>,
145) -> u8 {
146    if let Some(spec) = old_value {
147        if is_zero(spec) {
148            return emit_err(
149                "cannot delete a ref whose expected old value is all-zero (absent)",
150                exit::USAGE,
151            );
152        }
153        let expected = match resolve(store, mkit_dir, spec) {
154            Ok(h) => h,
155            Err(msg) => return emit_err(&msg, exit::DATAERR),
156        };
157        let current = match ns {
158            Namespace::Head => refs::read_ref(mkit_dir, name),
159            Namespace::Tag => refs::read_tag(mkit_dir, name),
160        };
161        match current {
162            Ok(Some(h)) if h == expected => {}
163            Ok(_) => {
164                return emit_err(
165                    "ref does not have the expected old value; not deleting",
166                    exit::GENERAL_ERROR,
167                );
168            }
169            Err(e) => return emit_err(&format!("read ref: {e}"), exit::GENERAL_ERROR),
170        }
171    }
172    let res = match ns {
173        // Branch delete uses the safe path — refuses the current branch.
174        Namespace::Head => refs::delete_ref_safe(mkit_dir, name),
175        Namespace::Tag => refs::delete_tag(mkit_dir, name),
176    };
177    match res {
178        Ok(()) => exit::OK,
179        Err(e) => emit_err(&format!("delete ref: {e}"), exit::GENERAL_ERROR),
180    }
181}
182
183/// Map a `refs/heads/<branch>` / `refs/tags/<name>` path to its namespace and
184/// short name. Returns `None` for any other ref.
185fn parse_ref(full: &str) -> Option<(Namespace, &str)> {
186    if let Some(b) = full.strip_prefix("refs/heads/") {
187        Some((Namespace::Head, b))
188    } else if let Some(t) = full.strip_prefix("refs/tags/") {
189        Some((Namespace::Tag, t))
190    } else {
191        None
192    }
193}
194
195/// Resolve a revision spec to a concrete object hash.
196fn resolve(store: &ObjectStore, mkit_dir: &std::path::Path, spec: &str) -> Result<Hash, String> {
197    revspec::resolve_revision(store, mkit_dir, spec)
198        .map_err(|e| format!("bad revision '{spec}': {e}"))
199}
200
201/// Is `s` git's all-zero object id (mkit's is 64 hex zeros)?
202fn is_zero(s: &str) -> bool {
203    s.len() == 64 && s.bytes().all(|b| b == b'0')
204}
205
206fn emit_err(msg: &str, code: u8) -> u8 {
207    let mut stderr = std::io::stderr().lock();
208    let _ = writeln!(stderr, "error: {msg}");
209    code
210}