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;
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)] struct BranchOpts {
#[arg(short = 'd', long)]
delete: bool,
#[arg(short = 'D')]
force_delete: bool,
#[arg(short = 'm', long)]
rename: bool,
#[arg(short = 'v', long)]
verbose: bool,
#[arg(long)]
list: bool,
#[arg(long, value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
contains: Option<String>,
#[arg(long = "no-contains", value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
no_contains: Option<String>,
#[arg(long, value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
merged: Option<String>,
#[arg(long = "no-merged", value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
no_merged: Option<String>,
#[arg(long = "show-current")]
show_current: bool,
#[arg(long, value_enum, default_value = "default")]
format: BranchFormat,
#[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,
};
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;
}
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);
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)"),
}
}
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);
};
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),
}
}
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>"));
};
let was = refs::read_ref(layout, name).ok().flatten();
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),
}
}
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),
};
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),
}
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),
}
match super::delete_ref_dropping_history_if_matches(layout, &old, hash) {
Ok(()) => {}
Err(refs::RefError::Conflict(_)) => {
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),
}
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
}
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()
}
}
struct BranchFilter {
contains: Option<Hash>,
no_contains: Option<Hash>,
merged: Option<Hash>,
no_merged: Option<Hash>,
}
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);
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)?,
})
}
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)
}
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);
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, None => {
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;
}
match star {
Some((sp, st)) => {
pi = sp + 1;
ti = st + 1;
star = Some((sp, st + 1));
}
None => return false,
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
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),
};
if !patterns.is_empty() {
refs.retain(|r| patterns.iter().any(|pat| glob_match(pat, &r.name)));
}
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
};
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 {
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 {
for r in &refs {
let _ = writeln!(stdout, "{} {}", marker_for(&r.name), r.name);
}
return exit::OK;
}
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
}
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"));
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"));
}
}