gabriel / muse public
branch.py python
852 lines 34.5 KB
Raw
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