mnem-cli 0.1.7

Command-line interface for mnem - Git for AI Agent Knowledge.
//! `mnem branch` - the `refs/heads/<name>` convenience verb.
//!
//! `branch` is a thin shell over [`super::refs`] that writes into the
//! `refs/heads/` namespace. It's split out as its own command so a Git
//! user's muscle memory (`git branch`, `git branch feature`,
//! `git branch -D old`) lands where they expect.
//!
//! Semantics:
//!
//! - `branch list` - every ref whose name begins with `refs/heads/`,
//!   plus the current head-commit CID marked with `*`.
//! - `branch create <name> [--from <cid>]` - writes
//!   `refs/heads/<name> -> <cid>`. `--from` accepts any commit CID in
//!   the repo; when absent, it defaults to the current head commit.
//!   Fails if the ref already exists or the repo has no commits yet.
//! - `branch delete <name>` - removes `refs/heads/<name>`. Fails if
//!   the ref does not exist. Does NOT refuse to delete the "current"
//!   branch in Q2 - mnem has no symbolic-ref-to-HEAD analog yet;
//!   every commit targets the heads list directly.
//!
//! Branches are named heads in the op-log graph (+
//! ). Every ref update goes through
//! `ReadonlyRepo::update_ref` (which wraps the write in an Operation
//! ), so `mnem branch create feat` shows up in
//! `mnem log` just like any other mutation.
//!
//! # Examples
//!
//! ```text
//! mnem branch list
//! mnem branch create feature/oauth
//! mnem branch create hotfix --from 01HZ...
//! mnem branch delete old-experiment
//! ```

use std::sync::Arc;

use mnem_core::HEADS_PREFIX;
use mnem_core::store::Blockstore;

use super::*;

/// `mnem branch` subcommand dispatcher.
#[derive(clap::Subcommand, Debug)]
pub(crate) enum BranchCmd {
    /// List every `refs/heads/<name>` ref, marking the head commit.
    List,
    /// Create a new branch. Fails if the name already exists.
    Create {
        /// Branch name. Stored as `refs/heads/<name>` in the View.
        name: String,
        /// Optional positional start-point: a commit CID, ref name,
        /// branch shortname, or `HEAD`. Mirrors `git branch <name>
        /// <start-point>`. When omitted (and `--from` also absent),
        /// defaults to the current head commit.
        ///
        /// audit-2026-04-25 C3-6: Pass-2 found Git users hit a wall
        /// with `mnem branch create feat main`; we now accept that
        /// shape as syntactic sugar for `--from main` and resolve
        /// the start-point through `resolve_commitish` so any commit
        /// CID, ref, or branch name works.
        start_point: Option<String>,
        /// Commit CID / ref / branch shortname to point the new
        /// branch at. Same resolver as the positional `start_point`.
        /// Conflicts with the positional form -- pass one or the
        /// other, not both.
        #[arg(long, conflicts_with = "start_point")]
        from: Option<String>,
    },
    /// Delete a branch. Fails if the name does not exist.
    Delete {
        /// Branch name to delete.
        name: String,
    },
}

pub(crate) fn run(override_path: Option<&Path>, cmd: BranchCmd) -> Result<()> {
    let data_dir = repo::locate_data_dir(override_path)?;
    let cfg = config::load(&data_dir)?;
    let (_dir, r, bs, _ohs) = repo::open_all(Some(data_dir.as_path()))?;

    match cmd {
        BranchCmd::List => list_branches(&r),
        BranchCmd::Create {
            name,
            start_point,
            from,
        } => {
            // C3-6: positional start-point is sugar for --from. The
            // clap `conflicts_with` annotation prevents both being
            // set, so an `or` is sufficient here.
            let resolved = from.or(start_point);
            create_branch(&r, &bs, &cfg, &name, resolved.as_deref())
        }
        BranchCmd::Delete { name } => delete_branch(&r, &cfg, &name),
    }
}

fn list_branches(r: &ReadonlyRepo) -> Result<()> {
    let refs = &r.view().refs;
    // BUG-38: use the active_branch pointer from View.extra as the
    // primary source of truth for which branch is current.  Fall back
    // to head-CID matching for repos that predate BUG-38 (old Views
    // have no active_branch in extra).
    let active_branch = r.view().active_branch().map(str::to_string);
    let head = r.view().heads.first().cloned();
    let mut any = false;
    for (name, target) in refs {
        let Some(short) = name.strip_prefix(HEADS_PREFIX) else {
            continue;
        };
        any = true;
        let marker = if active_branch.as_deref() == Some(name.as_str()) {
            "*"
        } else if active_branch.is_none() {
            // Legacy fallback: mark whichever branch points at the current head.
            match target {
                RefTarget::Normal { target } if Some(target) == head.as_ref() => "*",
                _ => " ",
            }
        } else {
            " "
        };
        let summary = match target {
            RefTarget::Normal { target } => format!("-> {target}"),
            RefTarget::Conflicted { adds, removes } => {
                format!("conflicted(+{} -{})", adds.len(), removes.len())
            }
        };
        println!("{marker} {short}  {summary}");
    }
    if !any {
        println!("<no branches>");
    }
    Ok(())
}

fn create_branch(
    r: &ReadonlyRepo,
    bs: &Arc<dyn Blockstore>,
    cfg: &config::Config,
    name: &str,
    from: Option<&str>,
) -> Result<()> {
    if name.is_empty() {
        bail!("branch name must not be empty");
    }
    // G6: reject refname characters that are invalid in most VCS tooling.
    // Extended in G6-patch to cover the full git check-ref-format spec.
    if name.contains(' ')
        || name.contains('\t')
        || name.contains('\n')
        || name.contains('\x00')
        || name.contains('~')
        || name.contains('^')
        || name.contains(':')
        || name.contains('?')
        || name.contains('*')
        || name.contains('[')
        || name.contains('\\')
        || name.contains("@{")
        || name.contains("..")
        || name.contains("//")
        || name.starts_with('/')
        || name.ends_with('/')
        || name.ends_with('.')
        || name.ends_with(".lock")
    {
        bail!(
            "invalid branch name `{name}`: branch names may not contain spaces, \
             control characters, `~`, `^`, `:`, `?`, `*`, `[`, `\\`, `@{{`, `..`, \
             `//`, trailing `.`, `.lock` suffix, or start/end with `/`"
        );
    }
    // Accept either a raw name (common) or a fully-qualified refname.
    // A raw name gets the `refs/heads/` prefix; a refname passes
    // through unchanged. This matches git's `git branch refs/foo` UX
    // loosely while preserving "happy path = bare name".
    let full = if name.starts_with(HEADS_PREFIX) {
        name.to_string()
    } else {
        format!("{HEADS_PREFIX}{name}")
    };
    if r.view().refs.contains_key(&full) {
        bail!("branch `{name}` already exists");
    }
    // C3-6: route the start-point through the shared `resolve_commitish`
    // resolver so a CID, a ref name (`refs/heads/main`), a branch
    // shortname (`main`), or `HEAD` all work identically. This is what
    // makes `mnem branch create feat main` behave like `git branch feat
    // main` instead of raising a "parsing CID" error.
    let target_cid = match from {
        Some(s) => super::resolve_commitish(r, s).context("resolving start-point")?,
        None => r
            .view()
            .heads
            .first()
            .cloned()
            .ok_or_else(|| anyhow!("repository has no commits yet; pass --from <cid>"))?,
    };

    // G_BUG: validate that target_cid actually points to a Commit block.
    // `mnem log --format=json` returns op-log CIDs, which are Operations,
    // not Commits. Without this check, the CID is accepted silently and
    // causes a deterministic crash in `mnem merge` later
    // ("missing field `change_id`"). Fetch the block and attempt a
    // Commit decode now so the user gets an actionable error immediately.
    {
        let bytes = bs
            .get(&target_cid)?
            .ok_or_else(|| anyhow!("block {target_cid} not found in blockstore"))?;
        if from_canonical_bytes::<Commit>(&bytes).is_err() {
            bail!(
                "`{target_cid}` does not decode as a commit.\n\
                 `mnem log --format=json` returns op CIDs; use \
                 `mnem show <op-cid>` to see the commit CID, or use \
                 `HEAD` / a branch name as the --from argument."
            );
        }
    }
    let new_r = r.update_ref(
        &full,
        None,
        Some(RefTarget::normal(target_cid.clone())),
        &config::author_string(cfg),
    )?;
    println!("created branch {name} -> {target_cid}");
    println!("  op_id    {}", new_r.op_id());
    Ok(())
}

fn delete_branch(r: &ReadonlyRepo, cfg: &config::Config, name: &str) -> Result<()> {
    if name.is_empty() {
        bail!("branch name must not be empty");
    }
    let full = if name.starts_with(HEADS_PREFIX) {
        name.to_string()
    } else {
        format!("{HEADS_PREFIX}{name}")
    };
    // BUG-40: refuse to delete the currently checked-out branch.
    if let Some(active) = r.view().active_branch() {
        if active == full.as_str() {
            bail!(
                "cannot delete branch '{name}': it is the currently checked-out branch; \
                 switch to another branch first"
            );
        }
    }
    let prev = r
        .view()
        .refs
        .get(&full)
        .ok_or_else(|| anyhow!("branch `{name}` does not exist"))?;
    let new_r = r.update_ref(&full, Some(prev), None, &config::author_string(cfg))?;
    println!("deleted branch {name}");
    println!("  op_id    {}", new_r.op_id());
    Ok(())
}