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::hash::Hash;
17use mkit_core::layout::RepoLayout;
18use mkit_core::object::Object;
19use mkit_core::ops::merge::is_ancestor;
20use mkit_core::refs::{self, Head};
21use mkit_core::store::ObjectStore;
22
23use super::revspec;
24use crate::clap_shim;
25use crate::exit;
26use crate::format;
27
28/// Abbreviated-id length for `branch -v`, matching `log`'s default and
29/// git's default `core.abbrev` (7) in shape (mkit's id is a BLAKE3 prefix).
30const DEFAULT_ABBREV: usize = 7;
31
32#[derive(Debug, Clone, Copy, ValueEnum)]
33enum BranchFormat {
34    Default,
35    Json,
36}
37
38#[derive(Debug, Parser)]
39#[command(
40    name = "mkit branch",
41    about = "List, create, rename, or delete branches."
42)]
43#[allow(clippy::struct_excessive_bools)] // clap option flags, not a state machine
44struct BranchOpts {
45    /// Delete the named branch (safe — refuses the current branch and a
46    /// non-existent branch).
47    #[arg(short = 'd', long)]
48    delete: bool,
49    /// Force-delete the named branch. mkit tracks no per-branch merge
50    /// state, so `-D` behaves like `-d`: it still refuses the branch HEAD
51    /// points at (that would leave HEAD dangling) and, like git, errors on
52    /// an absent branch rather than reporting a silent success.
53    #[arg(short = 'D')]
54    force_delete: bool,
55    /// Rename a branch. `branch -m <old> <new>` renames `<old>`;
56    /// `branch -m <new>` renames the current branch. Moves HEAD when the
57    /// renamed branch is the checked-out one.
58    #[arg(short = 'm', long)]
59    rename: bool,
60    /// Verbose list: also show each branch tip's abbreviated id and
61    /// commit subject (like `git branch -v`).
62    #[arg(short = 'v', long)]
63    verbose: bool,
64    /// List branches (explicit selector, like `git branch --list`). Listing
65    /// is already the default when no create/delete/rename flag is given;
66    /// `--list` additionally enables positional `<pattern>` glob filtering.
67    #[arg(long)]
68    list: bool,
69    /// List only branches whose tip has `<commit>` as an ancestor (default
70    /// HEAD when omitted, like `git branch --contains`).
71    #[arg(long, value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
72    contains: Option<String>,
73    /// List only branches whose tip does NOT contain `<commit>` (default HEAD).
74    #[arg(long = "no-contains", value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
75    no_contains: Option<String>,
76    /// List only branches already merged into `<commit>` (default HEAD) —
77    /// the branch tip is an ancestor of it (like `git branch --merged`).
78    #[arg(long, value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
79    merged: Option<String>,
80    /// List only branches NOT merged into `<commit>` (default HEAD).
81    #[arg(long = "no-merged", value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
82    no_merged: Option<String>,
83    /// Print the current branch name and exit (like `git branch
84    /// --show-current`). Empty output on a detached HEAD.
85    #[arg(long = "show-current")]
86    show_current: bool,
87    /// Output format for the list form. JSONL with `--format=json`.
88    #[arg(long, value_enum, default_value = "default")]
89    format: BranchFormat,
90    /// Positional arguments. In create/delete/rename mode these are branch
91    /// names; in list mode (`--list` or an ancestry filter) they are shell
92    /// glob patterns that filter the listing (like `git branch --list`).
93    #[arg(num_args = 0..)]
94    names: Vec<String>,
95}
96
97#[must_use]
98pub fn run(args: &[String]) -> u8 {
99    let opts = match clap_shim::parse::<BranchOpts>("mkit branch", args) {
100        Ok(o) => o,
101        Err(code) => return code,
102    };
103    let cwd = match std::env::current_dir() {
104        Ok(p) => p,
105        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
106    };
107    let layout = match super::resolve_layout(&cwd) {
108        Ok(layout) => layout,
109        Err(code) => return code,
110    };
111
112    // `--show-current`: print the checked-out branch (nothing when
113    // detached), then exit — like `git branch --show-current`.
114    if opts.show_current {
115        if let Ok(refs::Head::Branch(name)) = refs::read_head(&layout) {
116            let mut stdout = std::io::stdout().lock();
117            let _ = writeln!(stdout, "{name}");
118        }
119        return exit::OK;
120    }
121
122    // `-m` / `-d` / `-D` are mutually exclusive mode flags.
123    let mode_flags = u8::from(opts.delete) + u8::from(opts.force_delete) + u8::from(opts.rename);
124    if mode_flags > 1 {
125        return super::usage_error("usage: mkit branch [-d|-D|-m] ...  (modes are exclusive)");
126    }
127
128    let specs = FilterSpecs {
129        contains: opts.contains.as_deref(),
130        no_contains: opts.no_contains.as_deref(),
131        merged: opts.merged.as_deref(),
132        no_merged: opts.no_merged.as_deref(),
133    };
134    let has_filter = opts.list || specs.any();
135
136    if opts.rename || opts.delete || opts.force_delete {
137        if has_filter {
138            return super::usage_error(
139                "usage: mkit branch [--list|--contains|--no-contains|--merged|--no-merged] only \
140                 filter the listing — they cannot combine with -d/-D/-m",
141            );
142        }
143        if opts.rename {
144            return rename(&layout, &opts.names);
145        }
146        return delete(&layout, &opts.names, opts.force_delete);
147    }
148
149    let json = matches!(opts.format, BranchFormat::Json);
150
151    // In list mode (`--list` or an ancestry filter present) the positionals
152    // are glob patterns that filter the listing, like `git branch --list
153    // <pattern>`. Otherwise the positional is a branch name to create.
154    if has_filter {
155        return list(&layout, json, opts.verbose, &specs, &opts.names);
156    }
157    match opts.names.as_slice() {
158        [] => list(&layout, json, opts.verbose, &specs, &[]),
159        [name] => create(&layout, name),
160        _ => super::usage_error("usage: mkit branch <name>  (create takes one name)"),
161    }
162}
163
164/// `mkit branch <name>` — create a new branch at HEAD.
165fn create(layout: &RepoLayout, name: &str) -> u8 {
166    let Ok(Some(h)) = refs::resolve_head(layout) else {
167        return emit_err("no HEAD commit to branch from", exit::GENERAL_ERROR);
168    };
169    // `MustNotExist` (issue #206) refuses to silently clobber an
170    // existing branch of the same name. Route through
171    // `write_ref_recording_history` so the new branch picks up a
172    // fresh history-MMR journal (the empty pre-leaf root + this
173    // first append) on builds with `--features history-mmr`.
174    match super::write_ref_recording_history(layout, name, refs::RefWriteCondition::Missing, &h) {
175        Ok(()) => exit::OK,
176        Err(refs::RefError::Conflict(_)) => {
177            emit_err(&format!("branch '{name}' already exists"), exit::CANTCREAT)
178        }
179        Err(e) => emit_err(&format!("write {name}: {e}"), exit::CANTCREAT),
180    }
181}
182
183/// `mkit branch -d/-D <name>` — delete a branch.
184///
185/// Both `-d` and `-D` route through `delete_ref_recording_history`,
186/// which refuses to delete the branch HEAD currently points at (issue
187/// #206) — deleting the current branch would leave HEAD dangling, and
188/// git refuses this even under `-D`. mkit does not track per-branch
189/// merge status, so `-d` and `-D` behave identically here. Like git,
190/// **both** error on a missing branch (`error: branch '<name>' not
191/// found`); `-D` does not silently no-op, so a typo'd name is surfaced
192/// rather than swallowed.
193///
194/// On `--features history-mmr` builds, `delete_ref_recording_history`
195/// additionally destroys the branch's history-MMR journal partition
196/// (issue #648): without that, recreating a branch under the same name
197/// would reopen the deleted incarnation's non-empty journal and resume
198/// appending on top of its old leaves.
199fn delete(layout: &RepoLayout, names: &[String], force: bool) -> u8 {
200    let [name] = names else {
201        let flag = if force { "-D" } else { "-d" };
202        return super::usage_error(&format!("usage: mkit branch {flag} <name>"));
203    };
204    // Capture the tip before deletion for git's `Deleted branch <name>
205    // (was <hash>).` confirmation.
206    let was = refs::read_ref(layout, name).ok().flatten();
207    // Refuse to delete a branch a SIBLING worktree has checked out
208    // (#493) — delete_ref_safe below only knows about this tree's HEAD.
209    // Registry lock: atomic vs a concurrent checkout/worktree-add
210    // grabbing the branch between this check and the delete.
211    let _registry_lock = match super::acquire_worktrees_registry_lock(layout) {
212        Ok(l) => l,
213        Err(code) => return code,
214    };
215    match super::branch_checked_out_elsewhere(layout, name) {
216        Ok(Some(at)) => {
217            return super::error(
218                &format!("branch '{name}' is checked out at '{}'", at.display()),
219                crate::exit::DATAERR,
220            );
221        }
222        Ok(None) => {}
223        Err(e) => return super::error(&e, crate::exit::DATAERR),
224    }
225    match super::delete_ref_recording_history(layout, name) {
226        Ok(()) => {
227            let mut stderr = std::io::stderr().lock();
228            match was {
229                Some(h) => {
230                    let _ = writeln!(
231                        stderr,
232                        "Deleted branch {name} (was {}).",
233                        format::short_hash(&h, format::SUMMARY_ABBREV)
234                    );
235                }
236                None => {
237                    let _ = writeln!(stderr, "Deleted branch {name}.");
238                }
239            }
240            exit::OK
241        }
242        Err(refs::RefError::NotFound(_)) => {
243            emit_err(&format!("branch '{name}' not found"), exit::GENERAL_ERROR)
244        }
245        Err(e) => emit_err(&format!("delete {name}: {e}"), exit::GENERAL_ERROR),
246    }
247}
248
249/// `mkit branch -m [<old>] <new>` — rename a branch.
250///
251/// With two names renames `<old>` → `<new>`; with one name renames the
252/// current branch → `<new>`. Implemented as a CAS-guarded create of the
253/// destination (`RefWriteCondition::Missing` refuses to clobber) followed
254/// by deletion of the source, then a HEAD update when the source was the
255/// checked-out branch. The create routes through
256/// `write_ref_recording_history` so the renamed branch seeds a fresh
257/// history-MMR journal on `--features history-mmr` builds, exactly as a
258/// freshly created branch would. The source deletion routes through
259/// `delete_ref_dropping_history`, which on the same builds destroys the
260/// OLD name's journal partition (issue #648) — a rename always starts
261/// the new name with a fresh journal, so leaving the old name's journal
262/// behind would only serve to be wrongly inherited if that name is ever
263/// reused (e.g. renamed back, or a new unrelated branch of that name).
264fn rename(layout: &RepoLayout, names: &[String]) -> u8 {
265    let (old, new) = match names {
266        [new] => {
267            let Ok(refs::Head::Branch(cur)) = refs::read_head(layout) else {
268                return emit_err(
269                    "cannot rename: HEAD is detached (specify <old> <new>)",
270                    exit::GENERAL_ERROR,
271                );
272            };
273            (cur, new.clone())
274        }
275        [old, new] => (old.clone(), new.clone()),
276        _ => return super::usage_error("usage: mkit branch -m [<old>] <new>"),
277    };
278
279    if old == new {
280        return exit::OK;
281    }
282
283    let hash = match refs::read_ref(layout, &old) {
284        Ok(Some(h)) => h,
285        Ok(None) => return emit_err(&format!("branch '{old}' not found"), exit::GENERAL_ERROR),
286        Err(e) => return emit_err(&format!("read {old}: {e}"), exit::GENERAL_ERROR),
287    };
288
289    // Refuse to rename a branch a SIBLING worktree has checked out
290    // (#493): its HEAD would dangle on the old name. (Renaming the
291    // branch checked out HERE is fine — HEAD is moved below.)
292    // Registry lock: atomic vs a concurrent checkout/worktree-add.
293    let _registry_lock = match super::acquire_worktrees_registry_lock(layout) {
294        Ok(l) => l,
295        Err(code) => return code,
296    };
297    match super::branch_checked_out_elsewhere(layout, &old) {
298        Ok(Some(at)) => {
299            return emit_err(
300                &format!("branch '{old}' is checked out at '{}'", at.display()),
301                exit::DATAERR,
302            );
303        }
304        Ok(None) => {}
305        Err(e) => return emit_err(&e, exit::DATAERR),
306    }
307
308    // Create the destination first under a CAS that refuses to clobber an
309    // existing branch. Only after it lands do we drop the source, so a
310    // mid-operation failure never loses the branch tip.
311    match super::write_ref_recording_history(layout, &new, refs::RefWriteCondition::Missing, &hash)
312    {
313        Ok(()) => {}
314        Err(refs::RefError::Conflict(_)) => {
315            return emit_err(&format!("branch '{new}' already exists"), exit::CANTCREAT);
316        }
317        Err(e) => return emit_err(&format!("write {new}: {e}"), exit::CANTCREAT),
318    }
319
320    // CAS-guarded, not unconditional (#658): `hash` is the tip we read
321    // above, before the destination was even created. If a concurrent
322    // `commit` advanced `old` in the meantime (via its own
323    // Match-conditioned advance — see `commit.rs`'s `advance_head`),
324    // this delete now sees a different current value and refuses rather
325    // than silently deleting the ref out from under the just-landed
326    // commit, which would make it permanently unreferenced with no
327    // error to either caller.
328    match super::delete_ref_dropping_history_if_matches(layout, &old, hash) {
329        Ok(()) => {}
330        Err(refs::RefError::Conflict(_)) => {
331            // Roll back the destination we just created. It was seeded
332            // with `Missing`, so we know its exact current value is
333            // `hash` (nothing else should be racing to write a
334            // brand-new branch name) — use the same CAS-guarded delete
335            // so an unexpected concurrent write to `new` is reported
336            // rather than silently clobbered here too.
337            if let Err(e) = super::delete_ref_dropping_history_if_matches(layout, &new, hash) {
338                return emit_err(
339                    &format!(
340                        "branch '{old}' moved while renaming (a concurrent commit?) — rename \
341                         aborted, but rolling back the partially-created '{new}' also failed: \
342                         {e}; run `mkit branch -d {new}` manually, then re-run the rename"
343                    ),
344                    exit::GENERAL_ERROR,
345                );
346            }
347            return emit_err(
348                &format!(
349                    "branch '{old}' moved while renaming (a concurrent commit?) — rename \
350                     aborted, re-run"
351                ),
352                exit::GENERAL_ERROR,
353            );
354        }
355        Err(e) => return emit_err(&format!("delete {old}: {e}"), exit::GENERAL_ERROR),
356    }
357
358    // Move HEAD if we renamed the checked-out branch.
359    if let Ok(refs::Head::Branch(cur)) = refs::read_head(layout)
360        && cur == old
361        && let Err(e) = refs::write_head_branch(layout, &new)
362    {
363        return emit_err(&format!("update HEAD to {new}: {e}"), exit::GENERAL_ERROR);
364    }
365    exit::OK
366}
367
368/// The raw `--contains`/`--no-contains`/`--merged`/`--no-merged` specs as
369/// typed, resolved to commits only when a listing actually runs.
370struct FilterSpecs<'a> {
371    contains: Option<&'a str>,
372    no_contains: Option<&'a str>,
373    merged: Option<&'a str>,
374    no_merged: Option<&'a str>,
375}
376
377impl FilterSpecs<'_> {
378    fn any(&self) -> bool {
379        self.contains.is_some()
380            || self.no_contains.is_some()
381            || self.merged.is_some()
382            || self.no_merged.is_some()
383    }
384}
385
386/// The same specs after resolution to (tag-peeled) commit ids.
387struct BranchFilter {
388    contains: Option<Hash>,
389    no_contains: Option<Hash>,
390    merged: Option<Hash>,
391    no_merged: Option<Hash>,
392}
393
394/// Resolve each present spec to a commit id, peeling annotated tags like
395/// git. Returns `Err(message)` if a spec does not resolve.
396fn resolve_filter(
397    store: &ObjectStore,
398    layout: &RepoLayout,
399    specs: &FilterSpecs<'_>,
400) -> Result<BranchFilter, String> {
401    let resolve = |spec: Option<&str>| -> Result<Option<Hash>, String> {
402        match spec {
403            None => Ok(None),
404            Some(s) => {
405                let h = revspec::resolve_revision(store, layout, s)
406                    .map_err(|e| format!("bad revision '{s}': {e}"))?;
407                let h = super::log::peel_tags(store, h);
408                // The ancestry filters compare COMMITS; a tree/blob id would
409                // otherwise be treated as a parentless leaf and silently
410                // mis-filter (e.g. `--no-contains <tree>` keeps every branch
411                // and exits 0). Require a commit, like log/merge/cherry-pick.
412                match store.read_object(&h) {
413                    Ok(mkit_core::object::Object::Commit(_)) => Ok(Some(h)),
414                    Ok(_) => Err(format!("not a commit: '{s}'")),
415                    Err(e) => Err(format!("read '{s}': {e}")),
416                }
417            }
418        }
419    };
420    Ok(BranchFilter {
421        contains: resolve(specs.contains)?,
422        no_contains: resolve(specs.no_contains)?,
423        merged: resolve(specs.merged)?,
424        no_merged: resolve(specs.no_merged)?,
425    })
426}
427
428/// Whether a branch tip satisfies every active filter (AND). `contains C`
429/// keeps tips with C as an ancestor; `merged M` keeps tips that are
430/// ancestors of M; the `no_*` forms are their complements.
431fn tip_passes(store: &ObjectStore, filter: &BranchFilter, tip: &Hash) -> Result<bool, String> {
432    let anc = |a: Hash, d: Hash| is_ancestor(store, a, d).map_err(|e| format!("ancestry: {e}"));
433    if let Some(c) = filter.contains
434        && !anc(c, *tip)?
435    {
436        return Ok(false);
437    }
438    if let Some(c) = filter.no_contains
439        && anc(c, *tip)?
440    {
441        return Ok(false);
442    }
443    if let Some(m) = filter.merged
444        && !anc(*tip, m)?
445    {
446        return Ok(false);
447    }
448    if let Some(m) = filter.no_merged
449        && anc(*tip, m)?
450    {
451        return Ok(false);
452    }
453    Ok(true)
454}
455
456/// Shell-glob match for `branch --list <pattern>`, mirroring git's
457/// `wildmatch` without pathname mode: `*` matches any run (including `/`,
458/// so `feature/*` works), `?` matches one character, and `[...]` is a
459/// character class (`[a-z]`, leading `!`/`^` negates). A pattern with no
460/// metacharacters must match the whole name (so `main` matches only
461/// `main`). Backslash escapes the next metacharacter.
462pub(super) fn glob_match(pattern: &str, text: &str) -> bool {
463    let p: Vec<char> = pattern.chars().collect();
464    let t: Vec<char> = text.chars().collect();
465    let (mut pi, mut ti) = (0usize, 0usize);
466    // Backtrack point for the most recent `*`.
467    let mut star: Option<(usize, usize)> = None;
468    while ti < t.len() {
469        let advanced = if pi < p.len() {
470            match p[pi] {
471                '*' => {
472                    star = Some((pi, ti));
473                    pi += 1;
474                    true
475                }
476                '?' => {
477                    pi += 1;
478                    ti += 1;
479                    true
480                }
481                '[' => match match_class(&p, pi, t[ti]) {
482                    Some((matched, next_pi)) if matched => {
483                        pi = next_pi;
484                        ti += 1;
485                        true
486                    }
487                    Some(_) => false, // well-formed class, no match
488                    None => {
489                        // Malformed class — treat `[` literally.
490                        if t[ti] == '[' {
491                            pi += 1;
492                            ti += 1;
493                            true
494                        } else {
495                            false
496                        }
497                    }
498                },
499                '\\' if pi + 1 < p.len() => {
500                    if p[pi + 1] == t[ti] {
501                        pi += 2;
502                        ti += 1;
503                        true
504                    } else {
505                        false
506                    }
507                }
508                c => {
509                    if c == t[ti] {
510                        pi += 1;
511                        ti += 1;
512                        true
513                    } else {
514                        false
515                    }
516                }
517            }
518        } else {
519            false
520        };
521        if advanced {
522            continue;
523        }
524        // Mismatch: backtrack to the last `*`, extending what it consumed.
525        match star {
526            Some((sp, st)) => {
527                pi = sp + 1;
528                ti = st + 1;
529                star = Some((sp, st + 1));
530            }
531            None => return false,
532        }
533    }
534    // Trailing `*`s match the empty remainder.
535    while pi < p.len() && p[pi] == '*' {
536        pi += 1;
537    }
538    pi == p.len()
539}
540
541/// Match a bracket character class beginning at `p[start]` (the opening
542/// bracket) against `ch`. Returns `Some((matched, next_index))` for a
543/// well-formed class, where `next_index` is just past the closing bracket;
544/// returns `None` when there is no closing bracket (the caller then treats
545/// the opening bracket as a literal).
546fn match_class(p: &[char], start: usize, ch: char) -> Option<(bool, usize)> {
547    let mut i = start + 1;
548    let mut negate = false;
549    if i < p.len() && (p[i] == '!' || p[i] == '^') {
550        negate = true;
551        i += 1;
552    }
553    let mut matched = false;
554    let mut first = true;
555    while i < p.len() {
556        if p[i] == ']' && !first {
557            return Some((matched ^ negate, i + 1));
558        }
559        if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
560            if ch >= p[i] && ch <= p[i + 2] {
561                matched = true;
562            }
563            i += 3;
564        } else {
565            if p[i] == ch {
566                matched = true;
567            }
568            i += 1;
569        }
570        first = false;
571    }
572    None
573}
574
575fn list(
576    layout: &RepoLayout,
577    json: bool,
578    verbose: bool,
579    specs: &FilterSpecs<'_>,
580    patterns: &[String],
581) -> u8 {
582    let current = match refs::read_head(layout) {
583        Ok(Head::Branch(n)) => Some(n),
584        _ => None,
585    };
586    let mut refs = match refs::list_refs(layout) {
587        Ok(r) => r,
588        Err(e) => return emit_err(&format!("list refs: {e}"), exit::GENERAL_ERROR),
589    };
590
591    // Shell-glob name patterns (like `git branch --list <pattern>`): keep a
592    // branch if it matches ANY pattern. Applied before the ancestry walk so
593    // we don't resolve commits for branches the patterns already excluded.
594    if !patterns.is_empty() {
595        refs.retain(|r| patterns.iter().any(|pat| glob_match(pat, &r.name)));
596    }
597
598    // A filter or `-v` both need the object store; open it once.
599    let store = if verbose || specs.any() {
600        match ObjectStore::open(layout) {
601            Ok(s) => Some(s),
602            Err(e) => return emit_err(&format!("open store: {e}"), exit::GENERAL_ERROR),
603        }
604    } else {
605        None
606    };
607
608    // Apply listing filters (ancestry-based) before any rendering, so all
609    // three output modes share the same filtered set.
610    if specs.any() {
611        let store = store.as_ref().expect("store opened when filtering");
612        let filter = match resolve_filter(store, layout, specs) {
613            Ok(f) => f,
614            Err(e) => return emit_err(&e, exit::DATAERR),
615        };
616        let mut kept = Vec::with_capacity(refs.len());
617        for r in refs {
618            // A tip-less ref can't satisfy a commit filter, so skip it.
619            if let Some(h) = &r.hash {
620                match tip_passes(store, &filter, h) {
621                    Ok(true) => kept.push(r),
622                    Ok(false) => {}
623                    Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
624                }
625            }
626        }
627        refs = kept;
628    }
629
630    let mut stdout = std::io::stdout().lock();
631    if json {
632        for r in &refs {
633            let is_current = current.as_deref() == Some(r.name.as_str());
634            let _ = stdout.write_all(b"{");
635            let _ = write!(stdout, "\"name\":\"{}\"", format::json_escape(&r.name));
636            let _ = write!(stdout, ",\"current\":{is_current}");
637            if let Some(h) = &r.hash {
638                let _ = write!(stdout, ",\"hash\":\"{}\"", format::hex_hash(h));
639            } else {
640                let _ = stdout.write_all(b",\"hash\":null");
641            }
642            let _ = stdout.write_all(b"}\n");
643        }
644        return exit::OK;
645    }
646
647    let marker_for = |name: &str| {
648        current
649            .as_deref()
650            .map_or(' ', |cur| if cur == name { '*' } else { ' ' })
651    };
652
653    if !verbose {
654        // Default: `<marker> <name>` only — `git branch` omits the id.
655        for r in &refs {
656            let _ = writeln!(stdout, "{} {}", marker_for(&r.name), r.name);
657        }
658        return exit::OK;
659    }
660
661    // Verbose: `<marker> <name> <short> <subject>`, name column padded to
662    // the longest branch name (like `git branch -v`). The tip subject is
663    // the first line of the commit/remix message.
664    let store = match ObjectStore::open(layout) {
665        Ok(s) => s,
666        Err(e) => return emit_err(&format!("open store: {e}"), exit::GENERAL_ERROR),
667    };
668    let width = refs.iter().map(|r| r.name.len()).max().unwrap_or(0);
669    for r in &refs {
670        let marker = marker_for(&r.name);
671        match &r.hash {
672            Some(h) => {
673                let short = format::short_hash(h, DEFAULT_ABBREV);
674                let subject = tip_subject(&store, h);
675                let _ = writeln!(stdout, "{marker} {:<width$} {short} {subject}", r.name);
676            }
677            None => {
678                let _ = writeln!(stdout, "{marker} {:<width$}", r.name);
679            }
680        }
681    }
682    exit::OK
683}
684
685/// First line of a branch tip's commit (or remix) message, for `-v`.
686/// Returns an empty string if the tip can't be read or isn't a
687/// commit/remix — `-v` is a display aid and must not fail the listing.
688fn tip_subject(store: &ObjectStore, hash: &mkit_core::hash::Hash) -> String {
689    let message = match store.read_object(hash) {
690        Ok(Object::Commit(c)) => c.message,
691        Ok(Object::Remix(r)) => r.message,
692        _ => return String::new(),
693    };
694    String::from_utf8_lossy(&message)
695        .lines()
696        .next()
697        .unwrap_or("")
698        .to_owned()
699}
700
701use super::error as emit_err;
702
703#[cfg(test)]
704mod tests {
705    use super::glob_match;
706
707    #[test]
708    fn literal_matches_whole_name() {
709        assert!(glob_match("main", "main"));
710        assert!(!glob_match("main", "maintenance"));
711        assert!(!glob_match("main", " main"));
712    }
713
714    #[test]
715    fn star_matches_any_run_including_slash() {
716        assert!(glob_match("feat*", "feature"));
717        assert!(glob_match("feature/*", "feature/login"));
718        // `*` spans `/`, matching git's non-pathname wildmatch.
719        assert!(glob_match("*", "any/branch/name"));
720        assert!(glob_match("*login", "feature/login"));
721        assert!(!glob_match("feature/*", "main"));
722    }
723
724    #[test]
725    fn question_matches_single_char() {
726        assert!(glob_match("v?", "v1"));
727        assert!(!glob_match("v?", "v10"));
728    }
729
730    #[test]
731    fn char_classes_and_negation() {
732        assert!(glob_match("v[0-9]", "v3"));
733        assert!(!glob_match("v[0-9]", "vx"));
734        assert!(glob_match("v[!0-9]", "vx"));
735        assert!(!glob_match("v[!0-9]", "v3"));
736    }
737
738    #[test]
739    fn backslash_escapes_metacharacter() {
740        assert!(glob_match(r"feat\*", "feat*"));
741        assert!(!glob_match(r"feat\*", "feature"));
742    }
743
744    #[test]
745    fn star_backtracks() {
746        assert!(glob_match("a*b*c", "axxbyyc"));
747        assert!(!glob_match("a*b*c", "axxbyy"));
748    }
749}