branch.py
python
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9
docs: expand cache plan with all seven testing tiers and do…
Sonnet 4.6
131 days ago
| 1 | """``muse branch`` — list, create, rename, copy, and delete branches. |
| 2 | |
| 3 | Git-idiomatic flags:: |
| 4 | |
| 5 | muse branch # list all local branches |
| 6 | muse branch <name> # create branch at HEAD |
| 7 | muse branch <name> <start-point> # create at commit SHA, SHA prefix, or branch |
| 8 | muse branch -d <name> # safe delete (must be merged) |
| 9 | muse branch -D <name> # force delete |
| 10 | muse branch -dr <remote>/<branch> # delete local remote-tracking ref (no server call) |
| 11 | muse branch -Dr <remote>/<branch> # same, force (no merge check) |
| 12 | muse branch -m [<old>] <new> # rename (safe) |
| 13 | muse branch -M [<old>] <new> # rename (force) |
| 14 | muse branch -c [<src>] <dest> # copy (safe) |
| 15 | muse branch -C [<src>] <dest> # copy (force) |
| 16 | muse branch -v # list with last commit SHA + subject |
| 17 | muse branch -vv # also show upstream tracking ref |
| 18 | muse branch -r # list remote-tracking branches |
| 19 | muse branch -a # list local + remote-tracking branches |
| 20 | muse branch --merged [<commit>] # only branches merged into commit |
| 21 | muse branch --no-merged [<commit>] # only branches NOT merged into commit |
| 22 | muse branch --contains <commit> # only branches that contain commit |
| 23 | muse branch --sort name # sort by name (default) |
| 24 | muse branch --sort committeddate # sort by date of most recent commit |
| 25 | |
| 26 | To delete a branch on the remote **and** prune the local tracking ref in one |
| 27 | step, use ``muse push``:: |
| 28 | |
| 29 | muse push <remote> --delete <branch> |
| 30 | |
| 31 | Agents should pass ``--format json`` (or ``--json``) for machine-readable |
| 32 | output on all operations. The listing JSON schema is:: |
| 33 | |
| 34 | [ |
| 35 | { |
| 36 | "name": "feat/my-thing", |
| 37 | "current": false, |
| 38 | "commit_id": "<sha256> | null", |
| 39 | "committed_at": "2026-03-21T12:00:00+00:00 | null", |
| 40 | "last_message": "Add feature X", |
| 41 | "upstream": "origin/feat/my-thing" |
| 42 | }, |
| 43 | ... |
| 44 | ] |
| 45 | |
| 46 | Exit codes:: |
| 47 | |
| 48 | 0 — success |
| 49 | 1 — invalid branch name, branch not found, attempting to delete checked-out branch |
| 50 | """ |
| 51 | |
| 52 | from __future__ import annotations |
| 53 | |
| 54 | import argparse |
| 55 | import json |
| 56 | import logging |
| 57 | import pathlib |
| 58 | import sys |
| 59 | import tomllib |
| 60 | from typing import TypedDict |
| 61 | |
| 62 | from muse.cli.config import get_remote_head, read_branch_meta, write_branch_meta |
| 63 | from muse.core._types import short_id |
| 64 | from muse.core.paths import ref_path as _ref_path, heads_dir as _heads_dir, remotes_dir as _remotes_dir, config_toml_path as _config_toml_path |
| 65 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 66 | from muse.core.timing import start_timer |
| 67 | from muse.core.errors import ExitCode |
| 68 | from muse.core.repo import read_repo_id, require_repo |
| 69 | from muse.core.refs import read_ref |
| 70 | from muse.core.store import ( |
| 71 | get_head_commit_id, |
| 72 | read_commit, |
| 73 | read_current_branch, |
| 74 | resolve_commit_ref, |
| 75 | write_branch_ref, |
| 76 | write_head_branch, |
| 77 | write_text_atomic, |
| 78 | ) |
| 79 | from muse.core.validation import clamp_int, sanitize_display, validate_branch_name |
| 80 | |
| 81 | |
| 82 | type _Payload = dict[str, str | None] |
| 83 | logger = logging.getLogger(__name__) |
| 84 | |
| 85 | |
| 86 | class _BranchCreateJson(EnvelopeJson): |
| 87 | """JSON output for ``muse branch -b <name> --json``.""" |
| 88 | |
| 89 | action: str |
| 90 | branch: str |
| 91 | commit_id: str | None |
| 92 | intent: str | None |
| 93 | resumable: bool |
| 94 | |
| 95 | |
| 96 | class _BranchEntryJson(TypedDict): |
| 97 | name: str |
| 98 | current: bool |
| 99 | commit_id: str | None |
| 100 | committed_at: str | None |
| 101 | last_message: str | None |
| 102 | upstream: str | None |
| 103 | intent: str | None |
| 104 | resumable: bool |
| 105 | created_by: str | None |
| 106 | |
| 107 | |
| 108 | class _BranchListJson(EnvelopeJson): |
| 109 | """JSON output for ``muse branch --json``.""" |
| 110 | |
| 111 | branches: list[_BranchEntryJson] |
| 112 | |
| 113 | # --------------------------------------------------------------------------- |
| 114 | # ANSI helpers — emitted only when stdout is a TTY. |
| 115 | # --------------------------------------------------------------------------- |
| 116 | |
| 117 | _RESET = "\033[0m" |
| 118 | _BOLD = "\033[1m" |
| 119 | _DIM = "\033[2m" |
| 120 | _GREEN = "\033[32m" |
| 121 | _RED = "\033[31m" |
| 122 | _YELLOW = "\033[33m" |
| 123 | _CYAN = "\033[36m" |
| 124 | |
| 125 | |
| 126 | def _c(text: str, *codes: str, tty: bool) -> str: |
| 127 | """Wrap *text* in ANSI escape *codes* only when writing to a TTY.""" |
| 128 | if not tty: |
| 129 | return text |
| 130 | return "".join(codes) + text + _RESET |
| 131 | |
| 132 | |
| 133 | # --------------------------------------------------------------------------- |
| 134 | # Internal helpers |
| 135 | # --------------------------------------------------------------------------- |
| 136 | |
| 137 | |
| 138 | def _ref_file(root: pathlib.Path, branch: str) -> pathlib.Path: |
| 139 | """Return the ref-file path for a local branch.""" |
| 140 | return _ref_path(root, branch) |
| 141 | |
| 142 | |
| 143 | def _list_local_branches(root: pathlib.Path) -> list[str]: |
| 144 | """Return a sorted list of all local branch names. |
| 145 | |
| 146 | Only plain files are considered; directories, symlinks and any file not |
| 147 | directly under ``refs/heads/`` (e.g. lock files) are silently skipped. |
| 148 | """ |
| 149 | heads_dir = _heads_dir(root) |
| 150 | if not heads_dir.exists(): |
| 151 | return [] |
| 152 | return sorted( |
| 153 | p.relative_to(heads_dir).as_posix() |
| 154 | for p in heads_dir.rglob("*") |
| 155 | if p.is_file() and not p.name.startswith(".") |
| 156 | ) |
| 157 | |
| 158 | |
| 159 | def _list_remotes(root: pathlib.Path) -> list[str]: |
| 160 | """Return sorted remote-tracking branch names as ``remote/branch``. |
| 161 | |
| 162 | Only plain files are visited; symlinks, hidden files, and directories |
| 163 | are skipped to avoid leaking internal artefacts into the listing. |
| 164 | """ |
| 165 | remotes_dir = _remotes_dir(root) |
| 166 | if not remotes_dir.exists(): |
| 167 | return [] |
| 168 | results: list[str] = [] |
| 169 | for remote_dir in sorted(remotes_dir.iterdir()): |
| 170 | if not remote_dir.is_dir(): |
| 171 | continue |
| 172 | remote = remote_dir.name |
| 173 | for ref_file in sorted(remote_dir.rglob("*")): |
| 174 | if ref_file.is_file() and not ref_file.name.startswith("."): |
| 175 | branch_rel = ref_file.relative_to(remote_dir).as_posix() |
| 176 | results.append(f"{remote}/{branch_rel}") |
| 177 | return results |
| 178 | |
| 179 | |
| 180 | def _resolve_commit_id(root: pathlib.Path, b: str) -> str: |
| 181 | """Return the current commit ID for a branch listing entry. |
| 182 | |
| 183 | *b* is the display name (e.g. ``"main"`` or ``"remotes/origin/dev"``). |
| 184 | Remote entries are read from the remote tracking file under |
| 185 | ``.muse/remotes/``; local entries use the standard head ref. |
| 186 | """ |
| 187 | if b.startswith("remotes/"): |
| 188 | rest = b.removeprefix("remotes/") |
| 189 | remote, _, branch_name = rest.partition("/") |
| 190 | if branch_name: |
| 191 | return get_remote_head(remote, branch_name, root) or "" |
| 192 | return get_head_commit_id(root, b) or "" |
| 193 | |
| 194 | |
| 195 | def _upstream_for(root: pathlib.Path, branch: str) -> str | None: |
| 196 | """Return the upstream tracking ref for *branch*, or ``None`` if unset.""" |
| 197 | config_path = _config_toml_path(root) |
| 198 | if not config_path.exists(): |
| 199 | return None |
| 200 | try: |
| 201 | with config_path.open("rb") as f: |
| 202 | config = tomllib.load(f) |
| 203 | section = config.get("branch", {}).get(branch, {}) |
| 204 | remote: str | None = section.get("remote") |
| 205 | merge_ref: str | None = section.get("merge") |
| 206 | if remote and merge_ref: |
| 207 | short = merge_ref.removeprefix("refs/heads/") |
| 208 | return f"{remote}/{short}" |
| 209 | except Exception: |
| 210 | pass |
| 211 | return None |
| 212 | |
| 213 | |
| 214 | def _commit_ancestors(root: pathlib.Path, commit_id: str) -> set[str]: |
| 215 | """Return the set of all commit IDs reachable from *commit_id* (inclusive).""" |
| 216 | seen: set[str] = set() |
| 217 | queue: list[str] = [commit_id] |
| 218 | while queue: |
| 219 | cid = queue.pop() |
| 220 | if cid in seen: |
| 221 | continue |
| 222 | seen.add(cid) |
| 223 | rec = read_commit(root, cid) |
| 224 | if rec is None: |
| 225 | continue |
| 226 | if rec.parent_commit_id: |
| 227 | queue.append(rec.parent_commit_id) |
| 228 | if rec.parent2_commit_id: |
| 229 | queue.append(rec.parent2_commit_id) |
| 230 | return seen |
| 231 | |
| 232 | |
| 233 | def _is_merged(root: pathlib.Path, branch: str, into: str) -> bool: |
| 234 | """Return ``True`` if the tip of *branch* is an ancestor of the tip of *into*.""" |
| 235 | branch_tip = get_head_commit_id(root, branch) |
| 236 | into_tip = get_head_commit_id(root, into) |
| 237 | if branch_tip is None or into_tip is None: |
| 238 | return False |
| 239 | return branch_tip in _commit_ancestors(root, into_tip) |
| 240 | |
| 241 | |
| 242 | def _contains_commit(root: pathlib.Path, branch: str, commit_id: str) -> bool: |
| 243 | """Return ``True`` if *commit_id* is reachable from the tip of *branch*.""" |
| 244 | tip = get_head_commit_id(root, branch) |
| 245 | if tip is None: |
| 246 | return False |
| 247 | return commit_id in _commit_ancestors(root, tip) |
| 248 | |
| 249 | |
| 250 | def _cleanup_empty_dirs(ref_file: pathlib.Path, heads_dir: pathlib.Path) -> None: |
| 251 | """Remove any empty parent directories left behind after unlinking *ref_file*.""" |
| 252 | for parent in ref_file.parents: |
| 253 | if parent == heads_dir: |
| 254 | break |
| 255 | try: |
| 256 | parent.rmdir() |
| 257 | except OSError: |
| 258 | break |
| 259 | |
| 260 | |
| 261 | def _resolve_start_point(root: pathlib.Path, repo_id: str, current: str, start_point: str) -> str: |
| 262 | """Resolve *start_point* to a full commit ID. |
| 263 | |
| 264 | Accepts branch names, full SHA-256 commit IDs, and abbreviated SHA |
| 265 | prefixes (any unambiguous prefix works). Returns the raw *start_point* |
| 266 | string unchanged if resolution fails — the caller is responsible for |
| 267 | surfacing a meaningful error in that case. |
| 268 | """ |
| 269 | # Try as branch name first — skip if it contains characters forbidden in |
| 270 | # branch names (e.g. ':' in sha256:-prefixed IDs) to avoid ValueError. |
| 271 | try: |
| 272 | branch_tip = get_head_commit_id(root, start_point) |
| 273 | if branch_tip is not None: |
| 274 | return branch_tip |
| 275 | except ValueError: |
| 276 | pass # Not a valid branch name — fall through to SHA resolution. |
| 277 | # Fall back to SHA / SHA-prefix resolution. |
| 278 | # resolve_commit_ref handles both bare hex and sha256:-prefixed IDs. |
| 279 | rec = resolve_commit_ref(root, repo_id, current, start_point) |
| 280 | if rec is not None: |
| 281 | return rec.commit_id |
| 282 | # Return as-is; the caller's write_branch_ref will expose the invalid ID. |
| 283 | return start_point |
| 284 | |
| 285 | |
| 286 | # --------------------------------------------------------------------------- |
| 287 | # CLI registration |
| 288 | # --------------------------------------------------------------------------- |
| 289 | |
| 290 | |
| 291 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 292 | """Register the ``muse branch`` subcommand and all its flags.""" |
| 293 | parser = subparsers.add_parser( |
| 294 | "branch", |
| 295 | help="List, create, rename, copy, or delete branches.", |
| 296 | description=__doc__, |
| 297 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 298 | ) |
| 299 | parser.add_argument("args", nargs="*", help="Branch name(s) — context-sensitive.") |
| 300 | |
| 301 | # Mutually exclusive operation flags (mirrors git branch). |
| 302 | ops = parser.add_mutually_exclusive_group() |
| 303 | ops.add_argument( |
| 304 | "-d", "--delete", dest="op", action="store_const", const="delete", |
| 305 | help="Delete a branch (safe — must be fully merged).", |
| 306 | ) |
| 307 | ops.add_argument( |
| 308 | "-D", dest="op", action="store_const", const="force_delete", |
| 309 | help="Force-delete a branch regardless of merge status.", |
| 310 | ) |
| 311 | ops.add_argument( |
| 312 | "-m", "--move", dest="op", action="store_const", const="rename", |
| 313 | help="Rename a branch (safe).", |
| 314 | ) |
| 315 | ops.add_argument( |
| 316 | "-M", dest="op", action="store_const", const="force_rename", |
| 317 | help="Force-rename a branch.", |
| 318 | ) |
| 319 | ops.add_argument( |
| 320 | "-c", "--copy", dest="op", action="store_const", const="copy", |
| 321 | help="Copy a branch (safe).", |
| 322 | ) |
| 323 | ops.add_argument( |
| 324 | "-C", dest="op", action="store_const", const="force_copy", |
| 325 | help="Force-copy a branch.", |
| 326 | ) |
| 327 | |
| 328 | # Listing modifiers. |
| 329 | parser.add_argument( |
| 330 | "-v", action="count", default=0, dest="verbose", |
| 331 | help="Show last commit SHA + subject. Repeat (-vv) to also show upstream.", |
| 332 | ) |
| 333 | parser.add_argument( |
| 334 | "-r", "--remotes", action="store_true", |
| 335 | help="List remote-tracking branches.", |
| 336 | ) |
| 337 | parser.add_argument( |
| 338 | "-a", "--all", action="store_true", dest="all_branches", |
| 339 | help="List both local and remote-tracking branches.", |
| 340 | ) |
| 341 | parser.add_argument( |
| 342 | "--merged", metavar="COMMIT", nargs="?", const="HEAD", |
| 343 | help="Only list branches merged into COMMIT (default HEAD).", |
| 344 | ) |
| 345 | parser.add_argument( |
| 346 | "--no-merged", metavar="COMMIT", nargs="?", const="HEAD", |
| 347 | help="Only list branches NOT merged into COMMIT (default HEAD).", |
| 348 | ) |
| 349 | parser.add_argument( |
| 350 | "--contains", metavar="COMMIT", |
| 351 | help="Only list branches that contain COMMIT.", |
| 352 | ) |
| 353 | parser.add_argument( |
| 354 | "--sort", default="name", metavar="KEY", |
| 355 | choices=["name", "committeddate"], |
| 356 | help="Sort branches by 'name' (default) or 'committeddate'.", |
| 357 | ) |
| 358 | parser.add_argument( |
| 359 | "--intent", default=None, metavar="TEXT", |
| 360 | help="Short description of what this branch is for (stored in config).", |
| 361 | ) |
| 362 | parser.add_argument( |
| 363 | "--resumable", action="store_true", default=False, |
| 364 | help=( |
| 365 | "On create: mark this branch as a resumable agent checkpoint. " |
| 366 | "On list (no name): filter to resumable branches only." |
| 367 | ), |
| 368 | ) |
| 369 | parser.add_argument( |
| 370 | "--json", "-j", action="store_true", dest="json_out", |
| 371 | help="Emit machine-readable JSON.", |
| 372 | ) |
| 373 | parser.set_defaults(func=run, op=None) |
| 374 | |
| 375 | |
| 376 | # --------------------------------------------------------------------------- |
| 377 | # Command handler |
| 378 | # --------------------------------------------------------------------------- |
| 379 | |
| 380 | |
| 381 | def run(args: argparse.Namespace) -> None: |
| 382 | """List, create, rename, copy, or delete branches. |
| 383 | |
| 384 | Without a subcommand flag, lists all local branches. With ``--format json`` |
| 385 | the output is a stable JSON array; mutation ops (create, rename, copy, |
| 386 | delete) emit a single result object with an ``"action"`` key. |
| 387 | |
| 388 | Agent quickstart |
| 389 | ---------------- |
| 390 | :: |
| 391 | |
| 392 | muse branch --json # list all branches |
| 393 | muse branch --json --resumable # list resumable branches only |
| 394 | muse branch -b feat/thing --json # create branch |
| 395 | muse branch -d feat/thing --json # delete branch |
| 396 | |
| 397 | JSON fields (list mode — top-level is a bare array) |
| 398 | ---------------------------------------------------- |
| 399 | name Branch name. |
| 400 | current ``true`` for the currently checked-out branch. |
| 401 | commit_id Full ``sha256:…`` commit ID at the tip. |
| 402 | last_message Commit message at the tip. |
| 403 | upstream Upstream tracking ref; ``null`` if none. |
| 404 | intent Branch intent annotation (``--intent`` flag). |
| 405 | resumable ``true`` if the branch was created with ``--resumable``. |
| 406 | |
| 407 | JSON fields (mutation mode) |
| 408 | --------------------------- |
| 409 | action What was done: ``"created"``, ``"deleted"``, ``"renamed"``, etc. |
| 410 | name Branch name acted upon. |
| 411 | |
| 412 | Exit codes |
| 413 | ---------- |
| 414 | 0 Success. |
| 415 | 1 Invalid arguments, branch not found, or operation conflicts. |
| 416 | 2 Not inside a Muse repository. |
| 417 | """ |
| 418 | elapsed = start_timer() |
| 419 | positional: list[str] = args.args |
| 420 | op: str | None = args.op |
| 421 | verbose: int = clamp_int(args.verbose, 0, 4, 'verbose') |
| 422 | remotes_only: bool = args.remotes |
| 423 | all_branches: bool = args.all_branches |
| 424 | merged_into: str | None = args.merged |
| 425 | not_merged_into: str | None = args.no_merged |
| 426 | contains_commit: str | None = args.contains |
| 427 | sort_key: str = args.sort |
| 428 | intent: str | None = args.intent |
| 429 | resumable: bool = args.resumable |
| 430 | json_out: bool = args.json_out |
| 431 | tty: bool = sys.stdout.isatty() |
| 432 | |
| 433 | root = require_repo() |
| 434 | repo_id = read_repo_id(root) |
| 435 | current = read_current_branch(root) |
| 436 | heads_dir = _heads_dir(root) |
| 437 | |
| 438 | # ------------------------------------------------------------------ |
| 439 | # DELETE / FORCE-DELETE |
| 440 | # Supports two modes: |
| 441 | # muse branch -d|-D <local-branch> — delete a local branch |
| 442 | # muse branch -d|-D -r <remote>/<branch> — prune a remote-tracking ref |
| 443 | # ------------------------------------------------------------------ |
| 444 | if op in ("delete", "force_delete"): |
| 445 | if not positional: |
| 446 | if json_out: |
| 447 | print(json.dumps({"error": "usage", "message": "muse branch -d|-D [-r] <branch> …"})) |
| 448 | print("❌ Usage: muse branch -d|-D [-r] <branch> …", file=sys.stderr) |
| 449 | raise SystemExit(ExitCode.USER_ERROR) |
| 450 | |
| 451 | # -r flag: delete local remote-tracking refs (no server call). |
| 452 | if remotes_only: |
| 453 | from muse.cli.config import delete_remote_head |
| 454 | for spec in positional: |
| 455 | # Accept both "remote/branch" and "remotes/remote/branch" spellings. |
| 456 | clean = spec.removeprefix("remotes/") |
| 457 | slash = clean.find("/") |
| 458 | if slash == -1: |
| 459 | if json_out: |
| 460 | print(json.dumps({"error": "invalid_ref", "ref": spec, "message": "remote-tracking ref must be '<remote>/<branch>'"})) |
| 461 | print( |
| 462 | f"❌ Remote-tracking ref must be '<remote>/<branch>', got " |
| 463 | f"'{sanitize_display(spec)}'.", |
| 464 | file=sys.stderr, |
| 465 | ) |
| 466 | raise SystemExit(ExitCode.USER_ERROR) |
| 467 | remote_name = clean[:slash] |
| 468 | branch_name = clean[slash + 1:] |
| 469 | removed = delete_remote_head(remote_name, branch_name, root) |
| 470 | if not removed: |
| 471 | if json_out: |
| 472 | print(json.dumps({"error": "not_found", "ref": clean, "message": f"remote-tracking ref '{clean}' not found"})) |
| 473 | print( |
| 474 | f"❌ Remote-tracking ref '{sanitize_display(clean)}' not found.", |
| 475 | file=sys.stderr, |
| 476 | ) |
| 477 | raise SystemExit(ExitCode.USER_ERROR) |
| 478 | if json_out: |
| 479 | print(json.dumps({ |
| 480 | "action": "deleted_remote_tracking", |
| 481 | "remote": remote_name, |
| 482 | "branch": branch_name, |
| 483 | })) |
| 484 | else: |
| 485 | print( |
| 486 | f"Deleted remote-tracking ref " |
| 487 | f"{_c(sanitize_display(clean), _RED, tty=tty)}." |
| 488 | ) |
| 489 | return |
| 490 | |
| 491 | force = op == "force_delete" |
| 492 | for branch_name in positional: |
| 493 | try: |
| 494 | validate_branch_name(branch_name) |
| 495 | except ValueError as exc: |
| 496 | if json_out: |
| 497 | print(json.dumps({"error": "invalid_branch_name", "branch": branch_name, "message": str(exc)})) |
| 498 | print(f"❌ Invalid branch name: {sanitize_display(str(exc))}", file=sys.stderr) |
| 499 | raise SystemExit(ExitCode.USER_ERROR) |
| 500 | if branch_name == current: |
| 501 | if json_out: |
| 502 | print(json.dumps({"error": "current_branch", "branch": branch_name, "message": f"cannot delete the currently checked-out branch '{branch_name}'"})) |
| 503 | print( |
| 504 | f"❌ Cannot delete the currently checked-out branch " |
| 505 | f"'{sanitize_display(branch_name)}'.", |
| 506 | file=sys.stderr, |
| 507 | ) |
| 508 | raise SystemExit(ExitCode.USER_ERROR) |
| 509 | rf = _ref_file(root, branch_name) |
| 510 | if not rf.is_file(): |
| 511 | if json_out: |
| 512 | print(json.dumps({"error": "not_found", "branch": branch_name, "message": f"branch '{branch_name}' not found"})) |
| 513 | print(f"❌ Branch '{sanitize_display(branch_name)}' not found.", file=sys.stderr) |
| 514 | raise SystemExit(ExitCode.USER_ERROR) |
| 515 | if not force and not _is_merged(root, branch_name, current): |
| 516 | if json_out: |
| 517 | print(json.dumps({"error": "not_merged", "branch": branch_name, "message": f"branch '{branch_name}' is not fully merged", "hint": "use -D to force-delete"})) |
| 518 | print( |
| 519 | f"❌ Branch '{sanitize_display(branch_name)}' is not fully merged.\n" |
| 520 | f" Use -D to force-delete.", |
| 521 | file=sys.stderr, |
| 522 | ) |
| 523 | raise SystemExit(ExitCode.USER_ERROR) |
| 524 | tip = read_ref(rf) or "" |
| 525 | rf.unlink() |
| 526 | _cleanup_empty_dirs(rf, heads_dir) |
| 527 | if json_out: |
| 528 | print(json.dumps({"action": "deleted", "branch": branch_name, "was": tip})) |
| 529 | else: |
| 530 | short = short_id(tip) if tip else "unknown" |
| 531 | print( |
| 532 | f"Deleted branch {_c(sanitize_display(branch_name), _RED, tty=tty)} " |
| 533 | f"({_c('was ' + short, _DIM, tty=tty)})." |
| 534 | ) |
| 535 | return |
| 536 | |
| 537 | # ------------------------------------------------------------------ |
| 538 | # RENAME / FORCE-RENAME |
| 539 | # ------------------------------------------------------------------ |
| 540 | if op in ("rename", "force_rename"): |
| 541 | force = op == "force_rename" |
| 542 | if len(positional) == 1: |
| 543 | old_name, new_name = current, positional[0] |
| 544 | elif len(positional) == 2: |
| 545 | old_name, new_name = positional[0], positional[1] |
| 546 | else: |
| 547 | if json_out: |
| 548 | print(json.dumps({"error": "usage", "message": "muse branch -m|-M [<old>] <new>"})) |
| 549 | print("❌ Usage: muse branch -m|-M [<old>] <new>", file=sys.stderr) |
| 550 | raise SystemExit(ExitCode.USER_ERROR) |
| 551 | for n in (old_name, new_name): |
| 552 | try: |
| 553 | validate_branch_name(n) |
| 554 | except ValueError as exc: |
| 555 | if json_out: |
| 556 | print(json.dumps({"error": "invalid_branch_name", "branch": n, "message": str(exc)})) |
| 557 | print(f"❌ Invalid branch name: {sanitize_display(str(exc))}", file=sys.stderr) |
| 558 | raise SystemExit(ExitCode.USER_ERROR) |
| 559 | src = _ref_file(root, old_name) |
| 560 | dst = _ref_file(root, new_name) |
| 561 | if not src.is_file(): |
| 562 | if json_out: |
| 563 | print(json.dumps({"error": "not_found", "branch": old_name, "message": f"branch '{old_name}' not found"})) |
| 564 | print(f"❌ Branch '{sanitize_display(old_name)}' not found.", file=sys.stderr) |
| 565 | raise SystemExit(ExitCode.USER_ERROR) |
| 566 | if dst.is_file() and not force: |
| 567 | if json_out: |
| 568 | print(json.dumps({"error": "already_exists", "branch": new_name, "message": f"branch '{new_name}' already exists", "hint": "use -M to force"})) |
| 569 | print( |
| 570 | f"❌ Branch '{sanitize_display(new_name)}' already exists. Use -M to force.", |
| 571 | file=sys.stderr, |
| 572 | ) |
| 573 | raise SystemExit(ExitCode.USER_ERROR) |
| 574 | tip = read_ref(src) or "" |
| 575 | if tip: |
| 576 | write_branch_ref(root, new_name, tip) |
| 577 | else: |
| 578 | write_text_atomic(dst, "") |
| 579 | src.unlink() |
| 580 | _cleanup_empty_dirs(src, heads_dir) |
| 581 | if old_name == current: |
| 582 | write_head_branch(root, new_name) |
| 583 | if json_out: |
| 584 | print(json.dumps({"action": "renamed", "from": old_name, "to": new_name})) |
| 585 | else: |
| 586 | print( |
| 587 | f"Renamed branch " |
| 588 | f"{_c(sanitize_display(old_name), _YELLOW, tty=tty)} → " |
| 589 | f"{_c(sanitize_display(new_name), _GREEN, tty=tty)}." |
| 590 | ) |
| 591 | return |
| 592 | |
| 593 | # ------------------------------------------------------------------ |
| 594 | # COPY / FORCE-COPY |
| 595 | # ------------------------------------------------------------------ |
| 596 | if op in ("copy", "force_copy"): |
| 597 | force = op == "force_copy" |
| 598 | if len(positional) == 1: |
| 599 | src_name, dst_name = current, positional[0] |
| 600 | elif len(positional) == 2: |
| 601 | src_name, dst_name = positional[0], positional[1] |
| 602 | else: |
| 603 | if json_out: |
| 604 | print(json.dumps({"error": "usage", "message": "muse branch -c|-C [<src>] <dest>"})) |
| 605 | print("❌ Usage: muse branch -c|-C [<src>] <dest>", file=sys.stderr) |
| 606 | raise SystemExit(ExitCode.USER_ERROR) |
| 607 | for n in (src_name, dst_name): |
| 608 | try: |
| 609 | validate_branch_name(n) |
| 610 | except ValueError as exc: |
| 611 | if json_out: |
| 612 | print(json.dumps({"error": "invalid_branch_name", "branch": n, "message": str(exc)})) |
| 613 | print(f"❌ Invalid branch name: {sanitize_display(str(exc))}", file=sys.stderr) |
| 614 | raise SystemExit(ExitCode.USER_ERROR) |
| 615 | src = _ref_file(root, src_name) |
| 616 | dst = _ref_file(root, dst_name) |
| 617 | if not src.is_file(): |
| 618 | if json_out: |
| 619 | print(json.dumps({"error": "not_found", "branch": src_name, "message": f"branch '{src_name}' not found"})) |
| 620 | print(f"❌ Branch '{sanitize_display(src_name)}' not found.", file=sys.stderr) |
| 621 | raise SystemExit(ExitCode.USER_ERROR) |
| 622 | if dst.is_file() and not force: |
| 623 | if json_out: |
| 624 | print(json.dumps({"error": "already_exists", "branch": dst_name, "message": f"branch '{dst_name}' already exists", "hint": "use -C to force"})) |
| 625 | print( |
| 626 | f"❌ Branch '{sanitize_display(dst_name)}' already exists. Use -C to force.", |
| 627 | file=sys.stderr, |
| 628 | ) |
| 629 | raise SystemExit(ExitCode.USER_ERROR) |
| 630 | tip = read_ref(src) or "" |
| 631 | if tip: |
| 632 | write_branch_ref(root, dst_name, tip) |
| 633 | else: |
| 634 | write_text_atomic(dst, "") |
| 635 | if json_out: |
| 636 | print(json.dumps({"action": "copied", "from": src_name, "to": dst_name})) |
| 637 | else: |
| 638 | print( |
| 639 | f"Copied branch " |
| 640 | f"{_c(sanitize_display(src_name), _YELLOW, tty=tty)} → " |
| 641 | f"{_c(sanitize_display(dst_name), _GREEN, tty=tty)}." |
| 642 | ) |
| 643 | return |
| 644 | |
| 645 | # ------------------------------------------------------------------ |
| 646 | # CREATE |
| 647 | # ------------------------------------------------------------------ |
| 648 | if op is None and positional: |
| 649 | new_name = positional[0] |
| 650 | start_point: str | None = positional[1] if len(positional) > 1 else None |
| 651 | try: |
| 652 | validate_branch_name(new_name) |
| 653 | except ValueError as exc: |
| 654 | if json_out: |
| 655 | print(json.dumps({"error": "invalid_branch_name", "branch": new_name, "message": str(exc)})) |
| 656 | print(f"❌ Invalid branch name: {sanitize_display(str(exc))}", file=sys.stderr) |
| 657 | raise SystemExit(ExitCode.USER_ERROR) |
| 658 | rf = _ref_file(root, new_name) |
| 659 | if rf.is_file(): |
| 660 | # Branch exists. If --intent or --resumable given (no start_point), |
| 661 | # treat as a metadata update rather than a failed create. |
| 662 | if (intent is not None or resumable) and start_point is None: |
| 663 | write_branch_meta( |
| 664 | root, |
| 665 | new_name, |
| 666 | intent=intent, |
| 667 | resumable=resumable if resumable else None, |
| 668 | ) |
| 669 | meta = read_branch_meta(root, new_name) |
| 670 | if json_out: |
| 671 | print(json.dumps({ |
| 672 | "action": "updated", |
| 673 | "branch": new_name, |
| 674 | "intent": meta.get("intent"), |
| 675 | "resumable": bool(meta.get("resumable", False)), |
| 676 | })) |
| 677 | else: |
| 678 | parts: list[str] = [] |
| 679 | if intent is not None: |
| 680 | parts.append(f"intent={sanitize_display(intent)!r}") |
| 681 | if resumable: |
| 682 | parts.append("resumable=true") |
| 683 | print( |
| 684 | f"Updated branch {_c(sanitize_display(new_name), _YELLOW, tty=tty)}" |
| 685 | f"{' (' + ', '.join(parts) + ')' if parts else ''}." |
| 686 | ) |
| 687 | return |
| 688 | if json_out: |
| 689 | print(json.dumps({"error": "already_exists", "branch": new_name, "message": f"branch '{new_name}' already exists"})) |
| 690 | print(f"❌ Branch '{sanitize_display(new_name)}' already exists.", file=sys.stderr) |
| 691 | raise SystemExit(ExitCode.USER_ERROR) |
| 692 | |
| 693 | if start_point is not None: |
| 694 | # Resolve branch names, full SHAs, and abbreviated SHA prefixes. |
| 695 | sp_tip: str = _resolve_start_point(root, repo_id, current, start_point) |
| 696 | else: |
| 697 | sp_tip = get_head_commit_id(root, current) or "" |
| 698 | |
| 699 | if sp_tip: |
| 700 | write_branch_ref(root, new_name, sp_tip) |
| 701 | else: |
| 702 | write_text_atomic(rf, "") |
| 703 | |
| 704 | # Persist intent / resumable if supplied. |
| 705 | if intent is not None or resumable: |
| 706 | write_branch_meta( |
| 707 | root, |
| 708 | new_name, |
| 709 | intent=intent, |
| 710 | resumable=resumable if resumable else None, |
| 711 | ) |
| 712 | |
| 713 | if json_out: |
| 714 | print(json.dumps({ |
| 715 | **make_envelope(elapsed), |
| 716 | **_BranchCreateJson( |
| 717 | action="created", |
| 718 | branch=new_name, |
| 719 | commit_id=sp_tip or None, |
| 720 | intent=intent, |
| 721 | resumable=resumable, |
| 722 | ), |
| 723 | "from": start_point, |
| 724 | })) |
| 725 | else: |
| 726 | print(f"Created branch {_c(sanitize_display(new_name), _GREEN, tty=tty)}.") |
| 727 | return |
| 728 | |
| 729 | # ------------------------------------------------------------------ |
| 730 | # LIST |
| 731 | # ------------------------------------------------------------------ |
| 732 | local_branches = _list_local_branches(root) |
| 733 | if remotes_only: |
| 734 | display_branches = [f"remotes/{b}" for b in _list_remotes(root)] |
| 735 | elif all_branches: |
| 736 | display_branches = local_branches + [f"remotes/{b}" for b in _list_remotes(root)] |
| 737 | else: |
| 738 | display_branches = list(local_branches) |
| 739 | |
| 740 | # --resumable filter: only show branches marked resumable in config. |
| 741 | if resumable and not positional: |
| 742 | filtered_resumable: list[str] = [] |
| 743 | for b in display_branches: |
| 744 | local_b = b.removeprefix("remotes/") |
| 745 | meta = read_branch_meta(root, local_b) |
| 746 | if meta.get("resumable") is True: |
| 747 | filtered_resumable.append(b) |
| 748 | display_branches = filtered_resumable |
| 749 | |
| 750 | # --merged / --no-merged / --contains filters |
| 751 | if merged_into or not_merged_into or contains_commit: |
| 752 | resolved_current = current |
| 753 | |
| 754 | # Pre-compute ancestor sets once — not once per branch. |
| 755 | # _commit_ancestors walks the full commit DAG; recomputing it for every |
| 756 | # branch being checked is O(branches × commits) instead of O(commits). |
| 757 | _merged_ancestors: set[str] | None = None |
| 758 | if merged_into: |
| 759 | _into = resolved_current if merged_into == "HEAD" else merged_into |
| 760 | _into_tip = get_head_commit_id(root, _into) |
| 761 | _merged_ancestors = _commit_ancestors(root, _into_tip) if _into_tip else set() |
| 762 | |
| 763 | _not_merged_ancestors: set[str] | None = None |
| 764 | if not_merged_into: |
| 765 | _into = resolved_current if not_merged_into == "HEAD" else not_merged_into |
| 766 | _into_tip = get_head_commit_id(root, _into) |
| 767 | _not_merged_ancestors = _commit_ancestors(root, _into_tip) if _into_tip else set() |
| 768 | |
| 769 | def _passes(b: str) -> bool: |
| 770 | local_b = b.removeprefix("remotes/") |
| 771 | if _merged_ancestors is not None: |
| 772 | tip = get_head_commit_id(root, local_b) |
| 773 | if tip is None or tip not in _merged_ancestors: |
| 774 | return False |
| 775 | if _not_merged_ancestors is not None: |
| 776 | tip = get_head_commit_id(root, local_b) |
| 777 | if tip is not None and tip in _not_merged_ancestors: |
| 778 | return False |
| 779 | if contains_commit: |
| 780 | if not _contains_commit(root, local_b, contains_commit): |
| 781 | return False |
| 782 | return True |
| 783 | |
| 784 | display_branches = [b for b in display_branches if _passes(b)] |
| 785 | |
| 786 | # --sort: sort by committed date if requested. |
| 787 | # Name sort is the default (already applied by _list_local_branches). |
| 788 | if sort_key == "committeddate": |
| 789 | def _committed_ts(b: str) -> str: |
| 790 | cid = _resolve_commit_id(root, b) |
| 791 | if not cid: |
| 792 | return "" |
| 793 | rec = read_commit(root, cid) |
| 794 | return rec.committed_at.isoformat() if rec else "" |
| 795 | |
| 796 | display_branches = sorted(display_branches, key=_committed_ts, reverse=True) |
| 797 | |
| 798 | if json_out: |
| 799 | result: list[_BranchEntryJson] = [] |
| 800 | for b in display_branches: |
| 801 | local_b = b.removeprefix("remotes/") |
| 802 | commit_id = _resolve_commit_id(root, b) |
| 803 | rec = read_commit(root, commit_id) if commit_id else None |
| 804 | last_message: str | None = ( |
| 805 | sanitize_display(rec.message.splitlines()[0][:72]) if rec and rec.message else None |
| 806 | ) |
| 807 | upstream: str | None = _upstream_for(root, local_b) |
| 808 | meta = read_branch_meta(root, local_b) |
| 809 | branch_intent: str | None = meta.get("intent") or None # type: ignore[assignment] |
| 810 | branch_intent = sanitize_display(branch_intent) if branch_intent else None |
| 811 | branch_resumable: bool = bool(meta.get("resumable", False)) |
| 812 | created_by: str | None = (rec.agent_id if rec and rec.agent_id else None) |
| 813 | result.append({ |
| 814 | "name": b, |
| 815 | "current": local_b == current, |
| 816 | "commit_id": commit_id or None, |
| 817 | "committed_at": rec.committed_at.isoformat() if rec else None, |
| 818 | "last_message": last_message, |
| 819 | "upstream": upstream, |
| 820 | "intent": branch_intent, |
| 821 | "resumable": branch_resumable, |
| 822 | "created_by": created_by, |
| 823 | }) |
| 824 | print(json.dumps(result)) |
| 825 | return |
| 826 | |
| 827 | for b in display_branches: |
| 828 | is_remote_entry = b.startswith("remotes/") |
| 829 | local_b = b.removeprefix("remotes/") |
| 830 | is_current = (local_b == current) and not is_remote_entry |
| 831 | marker = _c("* ", _GREEN, tty=tty) if is_current else " " |
| 832 | # Build the display name once; apply sanitization before any coloring |
| 833 | # so that ANSI codes from _c() are not accidentally re-sanitized. |
| 834 | safe_name = sanitize_display(b) |
| 835 | name_str = _c(safe_name, _GREEN, tty=tty) if is_current else safe_name |
| 836 | if verbose >= 1: |
| 837 | commit_id = _resolve_commit_id(root, b) |
| 838 | short = short_id(commit_id) if commit_id else "(empty)" |
| 839 | rec = read_commit(root, commit_id) if commit_id else None |
| 840 | msg = sanitize_display(rec.message.splitlines()[0][:48]) if rec and rec.message else "" |
| 841 | short_str = _c(short, _YELLOW, tty=tty) |
| 842 | if verbose >= 2: |
| 843 | upstream = _upstream_for(root, local_b) |
| 844 | up_str = ( |
| 845 | f" [{_c(sanitize_display(upstream), _CYAN, tty=tty)}]" |
| 846 | if upstream else "" |
| 847 | ) |
| 848 | print(f"{marker}{name_str} {short_str}{up_str} {msg}") |
| 849 | else: |
| 850 | print(f"{marker}{name_str} {short_str} {msg}") |
| 851 | else: |
| 852 | print(f"{marker}{name_str}") |
File History
3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9
docs: expand cache plan with all seven testing tiers and do…
Sonnet 4.6
131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c
docs: docstring sprint for-each-ref→hotspots — idiomatic ru…
Sonnet 4.6
patch
138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9
fix(cursorignore): remove git-ism (.git/worktrees)
Human
minor
⚠
140 days ago