Skip to main content

mkit_cli/commands/
branch.rs

1//! `mkit branch` — list / create / delete branches.
2//!
3//! Output modes for the list form:
4//!
5//! - default — `<marker> <name>` per line, `*` marks current. Matches
6//!   `git branch`: the commit id is **not** shown (it moved behind `-v`).
7//! - `-v` / `--verbose` — `<marker> <name> <short> <subject>`, the name
8//!   column padded to the longest branch name, like `git branch -v`. The
9//!   abbreviated id is a BLAKE3 prefix (the documented hash-length
10//!   divergence), not a 40-hex SHA-1 prefix.
11//! - `--format=json` — JSONL: `{"name":"...","current":bool,"hash":"<64-hex>"}`.
12
13use std::io::Write;
14
15use clap::{Parser, ValueEnum};
16use mkit_core::object::Object;
17use mkit_core::refs::{self, Head};
18use mkit_core::store::ObjectStore;
19
20use crate::clap_shim;
21use crate::exit;
22use crate::format;
23
24/// Abbreviated-id length for `branch -v`, matching `log`'s default and
25/// git's default `core.abbrev` (7) in shape (mkit's id is a BLAKE3 prefix).
26const DEFAULT_ABBREV: usize = 7;
27
28#[derive(Debug, Clone, Copy, ValueEnum)]
29enum BranchFormat {
30    Default,
31    Json,
32}
33
34#[derive(Debug, Parser)]
35#[command(
36    name = "mkit branch",
37    about = "List, create, rename, or delete branches."
38)]
39#[allow(clippy::struct_excessive_bools)] // clap option flags, not a state machine
40struct BranchOpts {
41    /// Delete the named branch (safe — refuses the current branch and a
42    /// non-existent branch).
43    #[arg(short = 'd', long)]
44    delete: bool,
45    /// Force-delete the named branch. mkit tracks no per-branch merge
46    /// state, so `-D` behaves like `-d`: it still refuses the branch HEAD
47    /// points at (that would leave HEAD dangling) and, like git, errors on
48    /// an absent branch rather than reporting a silent success.
49    #[arg(short = 'D')]
50    force_delete: bool,
51    /// Rename a branch. `branch -m <old> <new>` renames `<old>`;
52    /// `branch -m <new>` renames the current branch. Moves HEAD when the
53    /// renamed branch is the checked-out one.
54    #[arg(short = 'm', long)]
55    rename: bool,
56    /// Verbose list: also show each branch tip's abbreviated id and
57    /// commit subject (like `git branch -v`).
58    #[arg(short = 'v', long)]
59    verbose: bool,
60    /// Output format for the list form. JSONL with `--format=json`.
61    #[arg(long, value_enum, default_value = "default")]
62    format: BranchFormat,
63    /// Positional arguments: branch name(s). Meaning depends on the mode.
64    #[arg(num_args = 0..=2)]
65    names: Vec<String>,
66}
67
68#[must_use]
69pub fn run(args: &[String]) -> u8 {
70    let opts = match clap_shim::parse::<BranchOpts>("mkit branch", args) {
71        Ok(o) => o,
72        Err(code) => return code,
73    };
74    let cwd = match std::env::current_dir() {
75        Ok(p) => p,
76        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
77    };
78    let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
79
80    // `-m` / `-d` / `-D` are mutually exclusive mode flags.
81    let mode_flags = u8::from(opts.delete) + u8::from(opts.force_delete) + u8::from(opts.rename);
82    if mode_flags > 1 {
83        return super::usage_error("usage: mkit branch [-d|-D|-m] ...  (modes are exclusive)");
84    }
85
86    if opts.rename {
87        return rename(&mkit_dir, &opts.names);
88    }
89    if opts.delete || opts.force_delete {
90        return delete(&mkit_dir, &opts.names, opts.force_delete);
91    }
92
93    match opts.names.as_slice() {
94        [] => list(
95            &cwd,
96            &mkit_dir,
97            matches!(opts.format, BranchFormat::Json),
98            opts.verbose,
99        ),
100        [name] => create(&mkit_dir, name),
101        _ => super::usage_error("usage: mkit branch <name>  (create takes one name)"),
102    }
103}
104
105/// `mkit branch <name>` — create a new branch at HEAD.
106fn create(mkit_dir: &std::path::Path, name: &str) -> u8 {
107    let Ok(Some(h)) = refs::resolve_head(mkit_dir) else {
108        return emit_err("no HEAD commit to branch from", exit::GENERAL_ERROR);
109    };
110    // `MustNotExist` (issue #206) refuses to silently clobber an
111    // existing branch of the same name. Route through
112    // `write_ref_recording_history` so the new branch picks up a
113    // fresh history-MMR journal (the empty pre-leaf root + this
114    // first append) on builds with `--features history-mmr`.
115    match super::write_ref_recording_history(mkit_dir, name, refs::RefWriteCondition::Missing, &h) {
116        Ok(()) => exit::OK,
117        Err(refs::RefError::Conflict(_)) => {
118            emit_err(&format!("branch '{name}' already exists"), exit::CANTCREAT)
119        }
120        Err(e) => emit_err(&format!("write {name}: {e}"), exit::CANTCREAT),
121    }
122}
123
124/// `mkit branch -d/-D <name>` — delete a branch.
125///
126/// Both `-d` and `-D` route through `delete_ref_safe`, which refuses to
127/// delete the branch HEAD currently points at (issue #206) — deleting
128/// the current branch would leave HEAD dangling, and git refuses this
129/// even under `-D`. mkit does not track per-branch merge status, so `-d`
130/// and `-D` behave identically here. Like git, **both** error on a
131/// missing branch (`error: branch '<name>' not found`); `-D` does not
132/// silently no-op, so a typo'd name is surfaced rather than swallowed.
133fn delete(mkit_dir: &std::path::Path, names: &[String], force: bool) -> u8 {
134    let [name] = names else {
135        let flag = if force { "-D" } else { "-d" };
136        return super::usage_error(&format!("usage: mkit branch {flag} <name>"));
137    };
138    match refs::delete_ref_safe(mkit_dir, name) {
139        Ok(()) => exit::OK,
140        Err(refs::RefError::NotFound(_)) => {
141            emit_err(&format!("branch '{name}' not found"), exit::GENERAL_ERROR)
142        }
143        Err(e) => emit_err(&format!("delete {name}: {e}"), exit::GENERAL_ERROR),
144    }
145}
146
147/// `mkit branch -m [<old>] <new>` — rename a branch.
148///
149/// With two names renames `<old>` → `<new>`; with one name renames the
150/// current branch → `<new>`. Implemented as a CAS-guarded create of the
151/// destination (`RefWriteCondition::Missing` refuses to clobber) followed
152/// by deletion of the source, then a HEAD update when the source was the
153/// checked-out branch. The create routes through
154/// `write_ref_recording_history` so the renamed branch seeds a fresh
155/// history-MMR journal on `--features history-mmr` builds, exactly as a
156/// freshly created branch would.
157fn rename(mkit_dir: &std::path::Path, names: &[String]) -> u8 {
158    let (old, new) = match names {
159        [new] => {
160            let Ok(refs::Head::Branch(cur)) = refs::read_head(mkit_dir) else {
161                return emit_err(
162                    "cannot rename: HEAD is detached (specify <old> <new>)",
163                    exit::GENERAL_ERROR,
164                );
165            };
166            (cur, new.clone())
167        }
168        [old, new] => (old.clone(), new.clone()),
169        _ => return super::usage_error("usage: mkit branch -m [<old>] <new>"),
170    };
171
172    if old == new {
173        return exit::OK;
174    }
175
176    let hash = match refs::read_ref(mkit_dir, &old) {
177        Ok(Some(h)) => h,
178        Ok(None) => return emit_err(&format!("branch '{old}' not found"), exit::GENERAL_ERROR),
179        Err(e) => return emit_err(&format!("read {old}: {e}"), exit::GENERAL_ERROR),
180    };
181
182    // Create the destination first under a CAS that refuses to clobber an
183    // existing branch. Only after it lands do we drop the source, so a
184    // mid-operation failure never loses the branch tip.
185    match super::write_ref_recording_history(
186        mkit_dir,
187        &new,
188        refs::RefWriteCondition::Missing,
189        &hash,
190    ) {
191        Ok(()) => {}
192        Err(refs::RefError::Conflict(_)) => {
193            return emit_err(&format!("branch '{new}' already exists"), exit::CANTCREAT);
194        }
195        Err(e) => return emit_err(&format!("write {new}: {e}"), exit::CANTCREAT),
196    }
197
198    if let Err(e) = refs::delete_ref(mkit_dir, &old) {
199        return emit_err(&format!("delete {old}: {e}"), exit::GENERAL_ERROR);
200    }
201
202    // Move HEAD if we renamed the checked-out branch.
203    if let Ok(refs::Head::Branch(cur)) = refs::read_head(mkit_dir)
204        && cur == old
205        && let Err(e) = refs::write_head_branch(mkit_dir, &new)
206    {
207        return emit_err(&format!("update HEAD to {new}: {e}"), exit::GENERAL_ERROR);
208    }
209    exit::OK
210}
211
212fn list(cwd: &std::path::Path, mkit_dir: &std::path::Path, json: bool, verbose: bool) -> u8 {
213    let current = match refs::read_head(mkit_dir) {
214        Ok(Head::Branch(n)) => Some(n),
215        _ => None,
216    };
217    let refs = match refs::list_refs(mkit_dir) {
218        Ok(r) => r,
219        Err(e) => return emit_err(&format!("list refs: {e}"), exit::GENERAL_ERROR),
220    };
221    let mut stdout = std::io::stdout().lock();
222    if json {
223        for r in &refs {
224            let is_current = current.as_deref() == Some(r.name.as_str());
225            let _ = stdout.write_all(b"{");
226            let _ = write!(stdout, "\"name\":\"{}\"", format::json_escape(&r.name));
227            let _ = write!(stdout, ",\"current\":{is_current}");
228            if let Some(h) = &r.hash {
229                let _ = write!(stdout, ",\"hash\":\"{}\"", format::hex_hash(h));
230            } else {
231                let _ = stdout.write_all(b",\"hash\":null");
232            }
233            let _ = stdout.write_all(b"}\n");
234        }
235        return exit::OK;
236    }
237
238    let marker_for = |name: &str| {
239        current
240            .as_deref()
241            .map_or(' ', |cur| if cur == name { '*' } else { ' ' })
242    };
243
244    if !verbose {
245        // Default: `<marker> <name>` only — `git branch` omits the id.
246        for r in &refs {
247            let _ = writeln!(stdout, "{} {}", marker_for(&r.name), r.name);
248        }
249        return exit::OK;
250    }
251
252    // Verbose: `<marker> <name> <short> <subject>`, name column padded to
253    // the longest branch name (like `git branch -v`). The tip subject is
254    // the first line of the commit/remix message.
255    let store = match ObjectStore::open(cwd) {
256        Ok(s) => s,
257        Err(e) => return emit_err(&format!("open store: {e}"), exit::GENERAL_ERROR),
258    };
259    let width = refs.iter().map(|r| r.name.len()).max().unwrap_or(0);
260    for r in &refs {
261        let marker = marker_for(&r.name);
262        match &r.hash {
263            Some(h) => {
264                let short = format::short_hash(h, DEFAULT_ABBREV);
265                let subject = tip_subject(&store, h);
266                let _ = writeln!(stdout, "{marker} {:<width$} {short} {subject}", r.name);
267            }
268            None => {
269                let _ = writeln!(stdout, "{marker} {:<width$}", r.name);
270            }
271        }
272    }
273    exit::OK
274}
275
276/// First line of a branch tip's commit (or remix) message, for `-v`.
277/// Returns an empty string if the tip can't be read or isn't a
278/// commit/remix — `-v` is a display aid and must not fail the listing.
279fn tip_subject(store: &ObjectStore, hash: &mkit_core::hash::Hash) -> String {
280    let message = match store.read_object(hash) {
281        Ok(Object::Commit(c)) => c.message,
282        Ok(Object::Remix(r)) => r.message,
283        _ => return String::new(),
284    };
285    String::from_utf8_lossy(&message)
286        .lines()
287        .next()
288        .unwrap_or("")
289        .to_owned()
290}
291
292fn emit_err(msg: &str, code: u8) -> u8 {
293    let mut stderr = std::io::stderr().lock();
294    let _ = writeln!(stderr, "error: {msg}");
295    code
296}