Skip to main content

mkit_cli/commands/
for_each_ref.rs

1//! `mkit for-each-ref [--format=<fmt>] [<pattern>...]` — iterate refs with
2//! an optional format string, like `git for-each-ref`.
3//!
4//! Default line: `<objectname> <objecttype>\t<refname>`. `--format`
5//! substitutes the common atoms `%(refname)`, `%(refname:short)`,
6//! `%(objectname)`, `%(objectname:short)`, `%(objecttype)` (and `%%` for a
7//! literal `%`). The object id is 64-hex BLAKE3. Optional patterns filter
8//! to refs whose full name equals or is under the pattern.
9
10use std::io::Write;
11
12use clap::Parser;
13use mkit_core::hash::Hash;
14use mkit_core::object::Object;
15use mkit_core::refs;
16use mkit_core::store::ObjectStore;
17
18use crate::clap_shim;
19use crate::exit;
20use crate::format;
21
22const DEFAULT_ABBREV: usize = 7;
23
24#[derive(Debug, Parser)]
25#[command(
26    name = "mkit for-each-ref",
27    about = "Iterate refs with an optional format."
28)]
29struct ForEachRefOpts {
30    /// Output format with `%(atom)` substitutions.
31    #[arg(long)]
32    format: Option<String>,
33    /// Optional ref-name patterns (prefix match) to filter the listing.
34    patterns: Vec<String>,
35}
36
37struct RefRow {
38    refname: String,
39    short: String,
40    hash: Hash,
41    objtype: &'static str,
42}
43
44#[must_use]
45pub fn run(args: &[String]) -> u8 {
46    let opts = match clap_shim::parse::<ForEachRefOpts>("mkit for-each-ref", args) {
47        Ok(o) => o,
48        Err(code) => return code,
49    };
50    let cwd = match std::env::current_dir() {
51        Ok(p) => p,
52        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
53    };
54    let layout = match super::resolve_layout(&cwd) {
55        Ok(layout) => layout,
56        Err(code) => return code,
57    };
58    let store = match ObjectStore::open(&layout) {
59        Ok(s) => s,
60        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
61    };
62
63    let mut rows: Vec<RefRow> = Vec::new();
64    let heads = match refs::list_refs(&layout) {
65        Ok(r) => r,
66        Err(e) => return emit_err(&format!("list refs: {e}"), exit::GENERAL_ERROR),
67    };
68    let tags = match refs::list_tags(&layout) {
69        Ok(r) => r,
70        Err(e) => return emit_err(&format!("list tags: {e}"), exit::GENERAL_ERROR),
71    };
72    push_rows(&store, &mut rows, &heads, "refs/heads/");
73    push_rows(&store, &mut rows, &tags, "refs/tags/");
74    match refs::list_remote_names(&layout) {
75        Ok(remotes) => {
76            for remote in remotes {
77                match refs::list_remote_refs(&layout, &remote) {
78                    Ok(rs) => {
79                        push_rows(&store, &mut rows, &rs, &format!("refs/remotes/{remote}/"));
80                    }
81                    Err(e) => {
82                        return emit_err(&format!("list remote refs: {e}"), exit::GENERAL_ERROR);
83                    }
84                }
85            }
86        }
87        Err(e) => return emit_err(&format!("list remotes: {e}"), exit::GENERAL_ERROR),
88    }
89    rows.sort_by(|a, b| a.refname.cmp(&b.refname));
90
91    if !opts.patterns.is_empty() {
92        rows.retain(|r| {
93            opts.patterns
94                .iter()
95                .any(|p| ref_matches_pattern(&r.refname, p))
96        });
97    }
98
99    let mut stdout = std::io::stdout().lock();
100    for r in &rows {
101        let line = match &opts.format {
102            Some(fmt) => match render_format(fmt, r) {
103                Ok(s) => s,
104                Err(msg) => return emit_err(&msg, exit::USAGE),
105            },
106            None => format!("{} {}\t{}", format::hex_hash(&r.hash), r.objtype, r.refname),
107        };
108        let _ = writeln!(stdout, "{line}");
109    }
110    exit::OK
111}
112
113/// git matches a ref against a literal pattern either completely or up to a
114/// `/` boundary, so `refs/heads` and `refs/heads/` both select every branch
115/// ref. Trim a trailing `/` from the pattern so it doesn't become an empty
116/// (always-failing) `refs/heads//` component.
117fn ref_matches_pattern(refname: &str, pattern: &str) -> bool {
118    let p = pattern.trim_end_matches('/');
119    refname == p || refname.starts_with(&format!("{p}/"))
120}
121
122fn push_rows(store: &ObjectStore, out: &mut Vec<RefRow>, rs: &[refs::Ref], prefix: &str) {
123    for r in rs {
124        let Some(h) = r.hash else { continue };
125        out.push(RefRow {
126            refname: format!("{prefix}{}", r.name),
127            short: r.name.clone(),
128            hash: h,
129            objtype: object_type_name(store, &h),
130        });
131    }
132}
133
134/// git-compatible object type of the ref target (`commit`/`tag`/…; mkit's
135/// `remix` is the one non-git type). Unreadable target → `commit` (the
136/// dominant case) rather than failing the whole listing.
137fn object_type_name(store: &ObjectStore, h: &Hash) -> &'static str {
138    match store.read_object(h) {
139        Ok(Object::Tag(_)) => "tag",
140        Ok(Object::Tree(_)) => "tree",
141        Ok(Object::Blob(_) | Object::ChunkedBlob(_)) => "blob",
142        Ok(Object::Remix(_)) => "remix",
143        // Commit is the dominant case; an unreadable target also defaults
144        // here rather than failing the whole listing.
145        _ => "commit",
146    }
147}
148
149/// Substitute `%(atom)` tokens (and `%%`) in `fmt` for one ref row.
150fn render_format(fmt: &str, r: &RefRow) -> Result<String, String> {
151    let mut out = String::with_capacity(fmt.len());
152    let mut chars = fmt.chars().peekable();
153    while let Some(c) = chars.next() {
154        if c != '%' {
155            out.push(c);
156            continue;
157        }
158        match chars.peek() {
159            Some('%') => {
160                chars.next();
161                out.push('%');
162            }
163            Some('(') => {
164                chars.next(); // consume '('
165                let mut atom = String::new();
166                let mut closed = false;
167                for ac in chars.by_ref() {
168                    if ac == ')' {
169                        closed = true;
170                        break;
171                    }
172                    atom.push(ac);
173                }
174                if !closed {
175                    return Err(format!("unterminated format atom in '{fmt}'"));
176                }
177                out.push_str(&atom_value(&atom, r)?);
178            }
179            _ => out.push('%'),
180        }
181    }
182    Ok(out)
183}
184
185fn atom_value(atom: &str, r: &RefRow) -> Result<String, String> {
186    Ok(match atom {
187        "refname" => r.refname.clone(),
188        "refname:short" => r.short.clone(),
189        "objectname" => format::hex_hash(&r.hash),
190        "objectname:short" => format::short_hash(&r.hash, DEFAULT_ABBREV),
191        "objecttype" => r.objtype.to_string(),
192        other => return Err(format!("unsupported format atom: %({other})")),
193    })
194}
195
196use super::error as emit_err;