mkit-cli 0.4.1

The mkit command-line tool: a content-addressed VCS with native attestation support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
//! `mkit branch` — list / create / delete branches.
//!
//! Output modes for the list form:
//!
//! - default — `<marker> <name>` per line, `*` marks current. Matches
//!   `git branch`: the commit id is **not** shown (it moved behind `-v`).
//! - `-v` / `--verbose` — `<marker> <name> <short> <subject>`, the name
//!   column padded to the longest branch name, like `git branch -v`. The
//!   abbreviated id is a BLAKE3 prefix (the documented hash-length
//!   divergence), not a 40-hex SHA-1 prefix.
//! - `--format=json` — JSONL: `{"name":"...","current":bool,"hash":"<64-hex>"}`.

use std::io::Write;

use clap::{Parser, ValueEnum};
use mkit_core::hash::Hash;
use mkit_core::layout::RepoLayout;
use mkit_core::object::Object;
use mkit_core::ops::merge::is_ancestor;
use mkit_core::refs::{self, Head};
use mkit_core::store::ObjectStore;

use super::revspec;
use crate::clap_shim;
use crate::exit;
use crate::format;

/// Abbreviated-id length for `branch -v`, matching `log`'s default and
/// git's default `core.abbrev` (7) in shape (mkit's id is a BLAKE3 prefix).
const DEFAULT_ABBREV: usize = 7;

#[derive(Debug, Clone, Copy, ValueEnum)]
enum BranchFormat {
    Default,
    Json,
}

#[derive(Debug, Parser)]
#[command(
    name = "mkit branch",
    about = "List, create, rename, or delete branches."
)]
#[allow(clippy::struct_excessive_bools)] // clap option flags, not a state machine
struct BranchOpts {
    /// Delete the named branch (safe — refuses the current branch and a
    /// non-existent branch).
    #[arg(short = 'd', long)]
    delete: bool,
    /// Force-delete the named branch. mkit tracks no per-branch merge
    /// state, so `-D` behaves like `-d`: it still refuses the branch HEAD
    /// points at (that would leave HEAD dangling) and, like git, errors on
    /// an absent branch rather than reporting a silent success.
    #[arg(short = 'D')]
    force_delete: bool,
    /// Rename a branch. `branch -m <old> <new>` renames `<old>`;
    /// `branch -m <new>` renames the current branch. Moves HEAD when the
    /// renamed branch is the checked-out one.
    #[arg(short = 'm', long)]
    rename: bool,
    /// Verbose list: also show each branch tip's abbreviated id and
    /// commit subject (like `git branch -v`).
    #[arg(short = 'v', long)]
    verbose: bool,
    /// List branches (explicit selector, like `git branch --list`). Listing
    /// is already the default when no create/delete/rename flag is given;
    /// `--list` additionally enables positional `<pattern>` glob filtering.
    #[arg(long)]
    list: bool,
    /// List only branches whose tip has `<commit>` as an ancestor (default
    /// HEAD when omitted, like `git branch --contains`).
    #[arg(long, value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
    contains: Option<String>,
    /// List only branches whose tip does NOT contain `<commit>` (default HEAD).
    #[arg(long = "no-contains", value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
    no_contains: Option<String>,
    /// List only branches already merged into `<commit>` (default HEAD) —
    /// the branch tip is an ancestor of it (like `git branch --merged`).
    #[arg(long, value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
    merged: Option<String>,
    /// List only branches NOT merged into `<commit>` (default HEAD).
    #[arg(long = "no-merged", value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
    no_merged: Option<String>,
    /// Print the current branch name and exit (like `git branch
    /// --show-current`). Empty output on a detached HEAD.
    #[arg(long = "show-current")]
    show_current: bool,
    /// Output format for the list form. JSONL with `--format=json`.
    #[arg(long, value_enum, default_value = "default")]
    format: BranchFormat,
    /// Positional arguments. In create/delete/rename mode these are branch
    /// names; in list mode (`--list` or an ancestry filter) they are shell
    /// glob patterns that filter the listing (like `git branch --list`).
    #[arg(num_args = 0..)]
    names: Vec<String>,
}

#[must_use]
pub fn run(args: &[String]) -> u8 {
    let opts = match clap_shim::parse::<BranchOpts>("mkit branch", args) {
        Ok(o) => o,
        Err(code) => return code,
    };
    let cwd = match std::env::current_dir() {
        Ok(p) => p,
        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
    };
    let layout = match super::resolve_layout(&cwd) {
        Ok(layout) => layout,
        Err(code) => return code,
    };

    // `--show-current`: print the checked-out branch (nothing when
    // detached), then exit — like `git branch --show-current`.
    if opts.show_current {
        if let Ok(refs::Head::Branch(name)) = refs::read_head(&layout) {
            let mut stdout = std::io::stdout().lock();
            let _ = writeln!(stdout, "{name}");
        }
        return exit::OK;
    }

    // `-m` / `-d` / `-D` are mutually exclusive mode flags.
    let mode_flags = u8::from(opts.delete) + u8::from(opts.force_delete) + u8::from(opts.rename);
    if mode_flags > 1 {
        return super::usage_error("usage: mkit branch [-d|-D|-m] ...  (modes are exclusive)");
    }

    let specs = FilterSpecs {
        contains: opts.contains.as_deref(),
        no_contains: opts.no_contains.as_deref(),
        merged: opts.merged.as_deref(),
        no_merged: opts.no_merged.as_deref(),
    };
    let has_filter = opts.list || specs.any();

    if opts.rename || opts.delete || opts.force_delete {
        if has_filter {
            return super::usage_error(
                "usage: mkit branch [--list|--contains|--no-contains|--merged|--no-merged] only \
                 filter the listing — they cannot combine with -d/-D/-m",
            );
        }
        if opts.rename {
            return rename(&layout, &opts.names);
        }
        return delete(&layout, &opts.names, opts.force_delete);
    }

    let json = matches!(opts.format, BranchFormat::Json);

    // In list mode (`--list` or an ancestry filter present) the positionals
    // are glob patterns that filter the listing, like `git branch --list
    // <pattern>`. Otherwise the positional is a branch name to create.
    if has_filter {
        return list(&layout, json, opts.verbose, &specs, &opts.names);
    }
    match opts.names.as_slice() {
        [] => list(&layout, json, opts.verbose, &specs, &[]),
        [name] => create(&layout, name),
        _ => super::usage_error("usage: mkit branch <name>  (create takes one name)"),
    }
}

/// `mkit branch <name>` — create a new branch at HEAD.
fn create(layout: &RepoLayout, name: &str) -> u8 {
    let Ok(Some(h)) = refs::resolve_head(layout) else {
        return emit_err("no HEAD commit to branch from", exit::GENERAL_ERROR);
    };
    // `MustNotExist` (issue #206) refuses to silently clobber an
    // existing branch of the same name. Route through
    // `write_ref_recording_history` so the new branch picks up a
    // fresh history-MMR journal (the empty pre-leaf root + this
    // first append) on builds with `--features history-mmr`.
    match super::write_ref_recording_history(layout, name, refs::RefWriteCondition::Missing, &h) {
        Ok(()) => exit::OK,
        Err(refs::RefError::Conflict(_)) => {
            emit_err(&format!("branch '{name}' already exists"), exit::CANTCREAT)
        }
        Err(e) => emit_err(&format!("write {name}: {e}"), exit::CANTCREAT),
    }
}

/// `mkit branch -d/-D <name>` — delete a branch.
///
/// Both `-d` and `-D` route through `delete_ref_recording_history`,
/// which refuses to delete the branch HEAD currently points at (issue
/// #206) — deleting the current branch would leave HEAD dangling, and
/// git refuses this even under `-D`. mkit does not track per-branch
/// merge status, so `-d` and `-D` behave identically here. Like git,
/// **both** error on a missing branch (`error: branch '<name>' not
/// found`); `-D` does not silently no-op, so a typo'd name is surfaced
/// rather than swallowed.
///
/// On `--features history-mmr` builds, `delete_ref_recording_history`
/// additionally destroys the branch's history-MMR journal partition
/// (issue #648): without that, recreating a branch under the same name
/// would reopen the deleted incarnation's non-empty journal and resume
/// appending on top of its old leaves.
fn delete(layout: &RepoLayout, names: &[String], force: bool) -> u8 {
    let [name] = names else {
        let flag = if force { "-D" } else { "-d" };
        return super::usage_error(&format!("usage: mkit branch {flag} <name>"));
    };
    // Capture the tip before deletion for git's `Deleted branch <name>
    // (was <hash>).` confirmation.
    let was = refs::read_ref(layout, name).ok().flatten();
    // Refuse to delete a branch a SIBLING worktree has checked out
    // (#493) — delete_ref_safe below only knows about this tree's HEAD.
    // Registry lock: atomic vs a concurrent checkout/worktree-add
    // grabbing the branch between this check and the delete.
    let _registry_lock = match super::acquire_worktrees_registry_lock(layout) {
        Ok(l) => l,
        Err(code) => return code,
    };
    match super::branch_checked_out_elsewhere(layout, name) {
        Ok(Some(at)) => {
            return super::error(
                &format!("branch '{name}' is checked out at '{}'", at.display()),
                crate::exit::DATAERR,
            );
        }
        Ok(None) => {}
        Err(e) => return super::error(&e, crate::exit::DATAERR),
    }
    match super::delete_ref_recording_history(layout, name) {
        Ok(()) => {
            let mut stderr = std::io::stderr().lock();
            match was {
                Some(h) => {
                    let _ = writeln!(
                        stderr,
                        "Deleted branch {name} (was {}).",
                        format::short_hash(&h, format::SUMMARY_ABBREV)
                    );
                }
                None => {
                    let _ = writeln!(stderr, "Deleted branch {name}.");
                }
            }
            exit::OK
        }
        Err(refs::RefError::NotFound(_)) => {
            emit_err(&format!("branch '{name}' not found"), exit::GENERAL_ERROR)
        }
        Err(e) => emit_err(&format!("delete {name}: {e}"), exit::GENERAL_ERROR),
    }
}

/// `mkit branch -m [<old>] <new>` — rename a branch.
///
/// With two names renames `<old>` → `<new>`; with one name renames the
/// current branch → `<new>`. Implemented as a CAS-guarded create of the
/// destination (`RefWriteCondition::Missing` refuses to clobber) followed
/// by deletion of the source, then a HEAD update when the source was the
/// checked-out branch. The create routes through
/// `write_ref_recording_history` so the renamed branch seeds a fresh
/// history-MMR journal on `--features history-mmr` builds, exactly as a
/// freshly created branch would. The source deletion routes through
/// `delete_ref_dropping_history`, which on the same builds destroys the
/// OLD name's journal partition (issue #648) — a rename always starts
/// the new name with a fresh journal, so leaving the old name's journal
/// behind would only serve to be wrongly inherited if that name is ever
/// reused (e.g. renamed back, or a new unrelated branch of that name).
fn rename(layout: &RepoLayout, names: &[String]) -> u8 {
    let (old, new) = match names {
        [new] => {
            let Ok(refs::Head::Branch(cur)) = refs::read_head(layout) else {
                return emit_err(
                    "cannot rename: HEAD is detached (specify <old> <new>)",
                    exit::GENERAL_ERROR,
                );
            };
            (cur, new.clone())
        }
        [old, new] => (old.clone(), new.clone()),
        _ => return super::usage_error("usage: mkit branch -m [<old>] <new>"),
    };

    if old == new {
        return exit::OK;
    }

    let hash = match refs::read_ref(layout, &old) {
        Ok(Some(h)) => h,
        Ok(None) => return emit_err(&format!("branch '{old}' not found"), exit::GENERAL_ERROR),
        Err(e) => return emit_err(&format!("read {old}: {e}"), exit::GENERAL_ERROR),
    };

    // Refuse to rename a branch a SIBLING worktree has checked out
    // (#493): its HEAD would dangle on the old name. (Renaming the
    // branch checked out HERE is fine — HEAD is moved below.)
    // Registry lock: atomic vs a concurrent checkout/worktree-add.
    let _registry_lock = match super::acquire_worktrees_registry_lock(layout) {
        Ok(l) => l,
        Err(code) => return code,
    };
    match super::branch_checked_out_elsewhere(layout, &old) {
        Ok(Some(at)) => {
            return emit_err(
                &format!("branch '{old}' is checked out at '{}'", at.display()),
                exit::DATAERR,
            );
        }
        Ok(None) => {}
        Err(e) => return emit_err(&e, exit::DATAERR),
    }

    // Create the destination first under a CAS that refuses to clobber an
    // existing branch. Only after it lands do we drop the source, so a
    // mid-operation failure never loses the branch tip.
    match super::write_ref_recording_history(layout, &new, refs::RefWriteCondition::Missing, &hash)
    {
        Ok(()) => {}
        Err(refs::RefError::Conflict(_)) => {
            return emit_err(&format!("branch '{new}' already exists"), exit::CANTCREAT);
        }
        Err(e) => return emit_err(&format!("write {new}: {e}"), exit::CANTCREAT),
    }

    // CAS-guarded, not unconditional (#658): `hash` is the tip we read
    // above, before the destination was even created. If a concurrent
    // `commit` advanced `old` in the meantime (via its own
    // Match-conditioned advance — see `commit.rs`'s `advance_head`),
    // this delete now sees a different current value and refuses rather
    // than silently deleting the ref out from under the just-landed
    // commit, which would make it permanently unreferenced with no
    // error to either caller.
    match super::delete_ref_dropping_history_if_matches(layout, &old, hash) {
        Ok(()) => {}
        Err(refs::RefError::Conflict(_)) => {
            // Roll back the destination we just created. It was seeded
            // with `Missing`, so we know its exact current value is
            // `hash` (nothing else should be racing to write a
            // brand-new branch name) — use the same CAS-guarded delete
            // so an unexpected concurrent write to `new` is reported
            // rather than silently clobbered here too.
            if let Err(e) = super::delete_ref_dropping_history_if_matches(layout, &new, hash) {
                return emit_err(
                    &format!(
                        "branch '{old}' moved while renaming (a concurrent commit?) — rename \
                         aborted, but rolling back the partially-created '{new}' also failed: \
                         {e}; run `mkit branch -d {new}` manually, then re-run the rename"
                    ),
                    exit::GENERAL_ERROR,
                );
            }
            return emit_err(
                &format!(
                    "branch '{old}' moved while renaming (a concurrent commit?) — rename \
                     aborted, re-run"
                ),
                exit::GENERAL_ERROR,
            );
        }
        Err(e) => return emit_err(&format!("delete {old}: {e}"), exit::GENERAL_ERROR),
    }

    // Move HEAD if we renamed the checked-out branch.
    if let Ok(refs::Head::Branch(cur)) = refs::read_head(layout)
        && cur == old
        && let Err(e) = refs::write_head_branch(layout, &new)
    {
        return emit_err(&format!("update HEAD to {new}: {e}"), exit::GENERAL_ERROR);
    }
    exit::OK
}

/// The raw `--contains`/`--no-contains`/`--merged`/`--no-merged` specs as
/// typed, resolved to commits only when a listing actually runs.
struct FilterSpecs<'a> {
    contains: Option<&'a str>,
    no_contains: Option<&'a str>,
    merged: Option<&'a str>,
    no_merged: Option<&'a str>,
}

impl FilterSpecs<'_> {
    fn any(&self) -> bool {
        self.contains.is_some()
            || self.no_contains.is_some()
            || self.merged.is_some()
            || self.no_merged.is_some()
    }
}

/// The same specs after resolution to (tag-peeled) commit ids.
struct BranchFilter {
    contains: Option<Hash>,
    no_contains: Option<Hash>,
    merged: Option<Hash>,
    no_merged: Option<Hash>,
}

/// Resolve each present spec to a commit id, peeling annotated tags like
/// git. Returns `Err(message)` if a spec does not resolve.
fn resolve_filter(
    store: &ObjectStore,
    layout: &RepoLayout,
    specs: &FilterSpecs<'_>,
) -> Result<BranchFilter, String> {
    let resolve = |spec: Option<&str>| -> Result<Option<Hash>, String> {
        match spec {
            None => Ok(None),
            Some(s) => {
                let h = revspec::resolve_revision(store, layout, s)
                    .map_err(|e| format!("bad revision '{s}': {e}"))?;
                let h = super::log::peel_tags(store, h);
                // The ancestry filters compare COMMITS; a tree/blob id would
                // otherwise be treated as a parentless leaf and silently
                // mis-filter (e.g. `--no-contains <tree>` keeps every branch
                // and exits 0). Require a commit, like log/merge/cherry-pick.
                match store.read_object(&h) {
                    Ok(mkit_core::object::Object::Commit(_)) => Ok(Some(h)),
                    Ok(_) => Err(format!("not a commit: '{s}'")),
                    Err(e) => Err(format!("read '{s}': {e}")),
                }
            }
        }
    };
    Ok(BranchFilter {
        contains: resolve(specs.contains)?,
        no_contains: resolve(specs.no_contains)?,
        merged: resolve(specs.merged)?,
        no_merged: resolve(specs.no_merged)?,
    })
}

/// Whether a branch tip satisfies every active filter (AND). `contains C`
/// keeps tips with C as an ancestor; `merged M` keeps tips that are
/// ancestors of M; the `no_*` forms are their complements.
fn tip_passes(store: &ObjectStore, filter: &BranchFilter, tip: &Hash) -> Result<bool, String> {
    let anc = |a: Hash, d: Hash| is_ancestor(store, a, d).map_err(|e| format!("ancestry: {e}"));
    if let Some(c) = filter.contains
        && !anc(c, *tip)?
    {
        return Ok(false);
    }
    if let Some(c) = filter.no_contains
        && anc(c, *tip)?
    {
        return Ok(false);
    }
    if let Some(m) = filter.merged
        && !anc(*tip, m)?
    {
        return Ok(false);
    }
    if let Some(m) = filter.no_merged
        && anc(*tip, m)?
    {
        return Ok(false);
    }
    Ok(true)
}

/// Shell-glob match for `branch --list <pattern>`, mirroring git's
/// `wildmatch` without pathname mode: `*` matches any run (including `/`,
/// so `feature/*` works), `?` matches one character, and `[...]` is a
/// character class (`[a-z]`, leading `!`/`^` negates). A pattern with no
/// metacharacters must match the whole name (so `main` matches only
/// `main`). Backslash escapes the next metacharacter.
pub(super) fn glob_match(pattern: &str, text: &str) -> bool {
    let p: Vec<char> = pattern.chars().collect();
    let t: Vec<char> = text.chars().collect();
    let (mut pi, mut ti) = (0usize, 0usize);
    // Backtrack point for the most recent `*`.
    let mut star: Option<(usize, usize)> = None;
    while ti < t.len() {
        let advanced = if pi < p.len() {
            match p[pi] {
                '*' => {
                    star = Some((pi, ti));
                    pi += 1;
                    true
                }
                '?' => {
                    pi += 1;
                    ti += 1;
                    true
                }
                '[' => match match_class(&p, pi, t[ti]) {
                    Some((matched, next_pi)) if matched => {
                        pi = next_pi;
                        ti += 1;
                        true
                    }
                    Some(_) => false, // well-formed class, no match
                    None => {
                        // Malformed class — treat `[` literally.
                        if t[ti] == '[' {
                            pi += 1;
                            ti += 1;
                            true
                        } else {
                            false
                        }
                    }
                },
                '\\' if pi + 1 < p.len() => {
                    if p[pi + 1] == t[ti] {
                        pi += 2;
                        ti += 1;
                        true
                    } else {
                        false
                    }
                }
                c => {
                    if c == t[ti] {
                        pi += 1;
                        ti += 1;
                        true
                    } else {
                        false
                    }
                }
            }
        } else {
            false
        };
        if advanced {
            continue;
        }
        // Mismatch: backtrack to the last `*`, extending what it consumed.
        match star {
            Some((sp, st)) => {
                pi = sp + 1;
                ti = st + 1;
                star = Some((sp, st + 1));
            }
            None => return false,
        }
    }
    // Trailing `*`s match the empty remainder.
    while pi < p.len() && p[pi] == '*' {
        pi += 1;
    }
    pi == p.len()
}

/// Match a bracket character class beginning at `p[start]` (the opening
/// bracket) against `ch`. Returns `Some((matched, next_index))` for a
/// well-formed class, where `next_index` is just past the closing bracket;
/// returns `None` when there is no closing bracket (the caller then treats
/// the opening bracket as a literal).
fn match_class(p: &[char], start: usize, ch: char) -> Option<(bool, usize)> {
    let mut i = start + 1;
    let mut negate = false;
    if i < p.len() && (p[i] == '!' || p[i] == '^') {
        negate = true;
        i += 1;
    }
    let mut matched = false;
    let mut first = true;
    while i < p.len() {
        if p[i] == ']' && !first {
            return Some((matched ^ negate, i + 1));
        }
        if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
            if ch >= p[i] && ch <= p[i + 2] {
                matched = true;
            }
            i += 3;
        } else {
            if p[i] == ch {
                matched = true;
            }
            i += 1;
        }
        first = false;
    }
    None
}

fn list(
    layout: &RepoLayout,
    json: bool,
    verbose: bool,
    specs: &FilterSpecs<'_>,
    patterns: &[String],
) -> u8 {
    let current = match refs::read_head(layout) {
        Ok(Head::Branch(n)) => Some(n),
        _ => None,
    };
    let mut refs = match refs::list_refs(layout) {
        Ok(r) => r,
        Err(e) => return emit_err(&format!("list refs: {e}"), exit::GENERAL_ERROR),
    };

    // Shell-glob name patterns (like `git branch --list <pattern>`): keep a
    // branch if it matches ANY pattern. Applied before the ancestry walk so
    // we don't resolve commits for branches the patterns already excluded.
    if !patterns.is_empty() {
        refs.retain(|r| patterns.iter().any(|pat| glob_match(pat, &r.name)));
    }

    // A filter or `-v` both need the object store; open it once.
    let store = if verbose || specs.any() {
        match ObjectStore::open(layout) {
            Ok(s) => Some(s),
            Err(e) => return emit_err(&format!("open store: {e}"), exit::GENERAL_ERROR),
        }
    } else {
        None
    };

    // Apply listing filters (ancestry-based) before any rendering, so all
    // three output modes share the same filtered set.
    if specs.any() {
        let store = store.as_ref().expect("store opened when filtering");
        let filter = match resolve_filter(store, layout, specs) {
            Ok(f) => f,
            Err(e) => return emit_err(&e, exit::DATAERR),
        };
        let mut kept = Vec::with_capacity(refs.len());
        for r in refs {
            // A tip-less ref can't satisfy a commit filter, so skip it.
            if let Some(h) = &r.hash {
                match tip_passes(store, &filter, h) {
                    Ok(true) => kept.push(r),
                    Ok(false) => {}
                    Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
                }
            }
        }
        refs = kept;
    }

    let mut stdout = std::io::stdout().lock();
    if json {
        for r in &refs {
            let is_current = current.as_deref() == Some(r.name.as_str());
            let _ = stdout.write_all(b"{");
            let _ = write!(stdout, "\"name\":\"{}\"", format::json_escape(&r.name));
            let _ = write!(stdout, ",\"current\":{is_current}");
            if let Some(h) = &r.hash {
                let _ = write!(stdout, ",\"hash\":\"{}\"", format::hex_hash(h));
            } else {
                let _ = stdout.write_all(b",\"hash\":null");
            }
            let _ = stdout.write_all(b"}\n");
        }
        return exit::OK;
    }

    let marker_for = |name: &str| {
        current
            .as_deref()
            .map_or(' ', |cur| if cur == name { '*' } else { ' ' })
    };

    if !verbose {
        // Default: `<marker> <name>` only — `git branch` omits the id.
        for r in &refs {
            let _ = writeln!(stdout, "{} {}", marker_for(&r.name), r.name);
        }
        return exit::OK;
    }

    // Verbose: `<marker> <name> <short> <subject>`, name column padded to
    // the longest branch name (like `git branch -v`). The tip subject is
    // the first line of the commit/remix message.
    let store = match ObjectStore::open(layout) {
        Ok(s) => s,
        Err(e) => return emit_err(&format!("open store: {e}"), exit::GENERAL_ERROR),
    };
    let width = refs.iter().map(|r| r.name.len()).max().unwrap_or(0);
    for r in &refs {
        let marker = marker_for(&r.name);
        match &r.hash {
            Some(h) => {
                let short = format::short_hash(h, DEFAULT_ABBREV);
                let subject = tip_subject(&store, h);
                let _ = writeln!(stdout, "{marker} {:<width$} {short} {subject}", r.name);
            }
            None => {
                let _ = writeln!(stdout, "{marker} {:<width$}", r.name);
            }
        }
    }
    exit::OK
}

/// First line of a branch tip's commit (or remix) message, for `-v`.
/// Returns an empty string if the tip can't be read or isn't a
/// commit/remix — `-v` is a display aid and must not fail the listing.
fn tip_subject(store: &ObjectStore, hash: &mkit_core::hash::Hash) -> String {
    let message = match store.read_object(hash) {
        Ok(Object::Commit(c)) => c.message,
        Ok(Object::Remix(r)) => r.message,
        _ => return String::new(),
    };
    String::from_utf8_lossy(&message)
        .lines()
        .next()
        .unwrap_or("")
        .to_owned()
}

use super::error as emit_err;

#[cfg(test)]
mod tests {
    use super::glob_match;

    #[test]
    fn literal_matches_whole_name() {
        assert!(glob_match("main", "main"));
        assert!(!glob_match("main", "maintenance"));
        assert!(!glob_match("main", " main"));
    }

    #[test]
    fn star_matches_any_run_including_slash() {
        assert!(glob_match("feat*", "feature"));
        assert!(glob_match("feature/*", "feature/login"));
        // `*` spans `/`, matching git's non-pathname wildmatch.
        assert!(glob_match("*", "any/branch/name"));
        assert!(glob_match("*login", "feature/login"));
        assert!(!glob_match("feature/*", "main"));
    }

    #[test]
    fn question_matches_single_char() {
        assert!(glob_match("v?", "v1"));
        assert!(!glob_match("v?", "v10"));
    }

    #[test]
    fn char_classes_and_negation() {
        assert!(glob_match("v[0-9]", "v3"));
        assert!(!glob_match("v[0-9]", "vx"));
        assert!(glob_match("v[!0-9]", "vx"));
        assert!(!glob_match("v[!0-9]", "v3"));
    }

    #[test]
    fn backslash_escapes_metacharacter() {
        assert!(glob_match(r"feat\*", "feat*"));
        assert!(!glob_match(r"feat\*", "feature"));
    }

    #[test]
    fn star_backtracks() {
        assert!(glob_match("a*b*c", "axxbyyc"));
        assert!(!glob_match("a*b*c", "axxbyy"));
    }
}