1use 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
28const 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)] struct BranchOpts {
45 #[arg(short = 'd', long)]
48 delete: bool,
49 #[arg(short = 'D')]
54 force_delete: bool,
55 #[arg(short = 'm', long)]
59 rename: bool,
60 #[arg(short = 'v', long)]
63 verbose: bool,
64 #[arg(long)]
68 list: bool,
69 #[arg(long, value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
72 contains: Option<String>,
73 #[arg(long = "no-contains", value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
75 no_contains: Option<String>,
76 #[arg(long, value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
79 merged: Option<String>,
80 #[arg(long = "no-merged", value_name = "COMMIT", num_args = 0..=1, default_missing_value = "HEAD")]
82 no_merged: Option<String>,
83 #[arg(long = "show-current")]
86 show_current: bool,
87 #[arg(long, value_enum, default_value = "default")]
89 format: BranchFormat,
90 #[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 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 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 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
164fn 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 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
183fn 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 let was = refs::read_ref(layout, name).ok().flatten();
207 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
249fn 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 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 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 match super::delete_ref_dropping_history_if_matches(layout, &old, hash) {
329 Ok(()) => {}
330 Err(refs::RefError::Conflict(_)) => {
331 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 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
368struct 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
386struct BranchFilter {
388 contains: Option<Hash>,
389 no_contains: Option<Hash>,
390 merged: Option<Hash>,
391 no_merged: Option<Hash>,
392}
393
394fn 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 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
428fn 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
456pub(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 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, None => {
489 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 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 while pi < p.len() && p[pi] == '*' {
536 pi += 1;
537 }
538 pi == p.len()
539}
540
541fn 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 if !patterns.is_empty() {
595 refs.retain(|r| patterns.iter().any(|pat| glob_match(pat, &r.name)));
596 }
597
598 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 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 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 for r in &refs {
656 let _ = writeln!(stdout, "{} {}", marker_for(&r.name), r.name);
657 }
658 return exit::OK;
659 }
660
661 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
685fn 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 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}