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