Skip to content

Commit c1f0667

Browse files
authored
Merge pull request #123 from atomikpanda/feat/hub-worktree-layout
hub worktree layout
2 parents 2afa78e + ee3fa2b commit c1f0667

38 files changed

Lines changed: 3983 additions & 123 deletions

docs/superpowers/plans/2026-04-28-hub-worktree-layout.md

Lines changed: 2085 additions & 0 deletions
Large diffs are not rendered by default.

docs/superpowers/specs/2026-04-28-hub-worktree-layout-design.md

Lines changed: 271 additions & 0 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mothership"
3-
version = "0.1.1"
3+
version = "0.2.0"
44
description = "Phase-based workflow engine for multi-repo AI development"
55
requires-python = ">=3.14"
66
dependencies = [
@@ -33,4 +33,5 @@ dev = [
3333
"pytest>=8.0",
3434
"pytest-tmp-files>=0.0.2",
3535
"pytest-asyncio>=0.23",
36+
"coverage>=7.13.5",
3637
]

src/mship/cli/audit.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,93 @@ def audit(
3535
output.error(str(e))
3636
raise typer.Exit(code=1)
3737

38+
# Surface passive worktree issues (passive_drift, passive_fetch_failed,
39+
# passive_dirty_worktree). Iterates state.tasks; for each (task, repo)
40+
# in task.passive_repos, audits the worktree and merges issues into
41+
# the matching RepoAudit entry in `report`.
42+
try:
43+
from mship.core.repo_state import (
44+
audit_passive_worktrees, RepoAudit, AuditReport, Issue,
45+
)
46+
state = container.state_manager().load()
47+
48+
# Group passive worktrees by task. For each task, run a separate
49+
# audit so messages can include the task slug for disambiguation.
50+
passive_per_task: list[tuple[str, dict, dict, dict]] = []
51+
for task_slug, task in state.tasks.items():
52+
if not task.passive_repos:
53+
continue
54+
paths: dict = {}
55+
refs: dict = {}
56+
canonicals: dict = {}
57+
for repo_name in task.passive_repos:
58+
if repo_name not in config.repos:
59+
continue
60+
rc = config.repos[repo_name]
61+
ref = getattr(rc, "expected_branch", None) or getattr(rc, "base_branch", None)
62+
if ref is None:
63+
continue
64+
wt = task.worktrees.get(repo_name)
65+
if wt is None:
66+
continue
67+
paths[repo_name] = wt
68+
refs[repo_name] = ref
69+
canonicals[repo_name] = rc.path
70+
if paths:
71+
passive_per_task.append((task_slug, paths, refs, canonicals))
72+
73+
extra_issues_per_repo: dict[str, list] = {}
74+
for task_slug, paths, refs, canonicals in passive_per_task:
75+
per_repo = audit_passive_worktrees(paths, refs, canonicals)
76+
for repo_name, issues in per_repo.items():
77+
if not issues:
78+
continue
79+
# Tag each issue with the task slug for disambiguation
80+
tagged = [
81+
Issue(i.code, i.severity,
82+
f"[task {task_slug}] {i.message}")
83+
for i in issues
84+
]
85+
extra_issues_per_repo.setdefault(repo_name, []).extend(tagged)
86+
87+
if extra_issues_per_repo:
88+
# Rebuild report.repos so RepoAudit instances containing passive
89+
# repos pick up the merged issues.
90+
new_repos = []
91+
seen: set[str] = set()
92+
for r in report.repos:
93+
seen.add(r.name)
94+
extra = extra_issues_per_repo.get(r.name, [])
95+
if extra:
96+
new_repos.append(RepoAudit(
97+
name=r.name,
98+
path=r.path,
99+
current_branch=r.current_branch,
100+
issues=tuple(list(r.issues) + extra),
101+
))
102+
else:
103+
new_repos.append(r)
104+
# Repos with passive issues but not in the audit_repos report
105+
# (e.g. excluded by --repos filter). Append fresh entries.
106+
for repo_name, extra in extra_issues_per_repo.items():
107+
if repo_name in seen:
108+
continue
109+
rc = config.repos.get(repo_name)
110+
if rc is None:
111+
continue
112+
new_repos.append(RepoAudit(
113+
name=repo_name,
114+
path=rc.path,
115+
current_branch=None,
116+
issues=tuple(extra),
117+
))
118+
report = AuditReport(repos=tuple(new_repos))
119+
except Exception as e:
120+
# Best-effort: failing to audit passive worktrees should not break
121+
# the canonical audit output.
122+
if not json_output:
123+
output.warning(f"passive audit unavailable: {e}")
124+
38125
if json_output:
39126
import json as _json
40127
print(_json.dumps(report.to_json(workspace=config.workspace), indent=2))

src/mship/cli/doctor.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ def doctor():
1414

1515
config = container.config()
1616
shell = container.shell()
17-
checker = DoctorChecker(config, shell, state_dir=container.state_dir())
17+
checker = DoctorChecker(
18+
config,
19+
shell,
20+
state_dir=container.state_dir(),
21+
workspace_root=container.config_path().parent,
22+
)
1823
report = checker.run()
1924

2025
if output.is_tty:

src/mship/cli/exec.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ def test_cmd(
7878
resolved = resolve_for_command("exec", state, task, output)
7979
t = resolved.task
8080

81+
if t.active_repo and t.active_repo in t.passive_repos:
82+
output.error(
83+
f"Cannot run tests: active_repo '{t.active_repo}' is passive. "
84+
f"Switch to an affected repo first, or close & respawn with "
85+
f"`--repos {t.active_repo},...` to make it editable."
86+
)
87+
raise typer.Exit(code=1)
88+
8189
from pathlib import Path as _P
8290
from mship.cli._cwd_check import format_cwd_warning
8391
if t.active_repo is not None and t.active_repo in t.worktrees:

src/mship/cli/internal.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,32 @@ def check_commit(toplevel: str = typer.Argument(..., help="git rev-parse --show-
3030
try:
3131
tl = Path(toplevel).resolve()
3232
registered = [
33-
(slug, Path(wt).resolve())
33+
(slug, repo, Path(wt).resolve())
3434
for slug, task in state.tasks.items()
35-
for wt in task.worktrees.values()
35+
for repo, wt in task.worktrees.items()
3636
]
3737
except (OSError, RuntimeError):
3838
raise typer.Exit(code=0)
3939

4040
matched_task = None
41-
for slug, wt in registered:
41+
matched_repo: str | None = None
42+
for slug, repo, wt in registered:
4243
if tl == wt:
4344
matched_task = state.tasks[slug]
45+
matched_repo = repo
4446
break
4547

48+
if matched_task is not None and matched_repo in matched_task.passive_repos:
49+
import sys
50+
sys.stderr.write(
51+
f"⛔ mship: refusing commit — {tl} is a passive worktree of "
52+
f"`{matched_repo}` for task `{matched_task.slug}`.\n"
53+
f" To edit {matched_repo}, close this task and respawn with "
54+
f"`--repos {matched_repo},...`\n"
55+
f" (or `git commit --no-verify` to override).\n"
56+
)
57+
raise typer.Exit(code=1)
58+
4659
if matched_task is not None:
4760
# Reconcile gate (per-task, unchanged behavior)
4861
try:
@@ -88,7 +101,7 @@ def _fetcher(branches, worktrees_by_branch):
88101
f"\u26d4 mship: refusing commit — {tl} is not a registered worktree.\n"
89102
f" Active task worktrees:\n"
90103
)
91-
for slug, wt in registered:
104+
for slug, _repo, wt in registered:
92105
sys.stderr.write(f" {wt} ({slug})\n")
93106

94107
# If the rejected toplevel has uncommitted changes, it's almost
@@ -110,7 +123,7 @@ def _fetcher(branches, worktrees_by_branch):
110123
f"\n {tl} has uncommitted changes — looks like edits landed here\n"
111124
f" instead of the worktree. To move them:\n"
112125
)
113-
for slug, wt in registered:
126+
for slug, _repo, wt in registered:
114127
q_wt = shlex.quote(str(wt))
115128
sys.stderr.write(
116129
f" git -C {q_tl} stash push -u -m {slug}-misrouted\n"

src/mship/cli/phase.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ def phase(
2828
resolved = resolve_for_command("phase", state, task, output)
2929
t = resolved.task
3030

31+
if t.active_repo and t.active_repo in t.passive_repos:
32+
output.error(
33+
f"Cannot transition phase: active_repo '{t.active_repo}' is passive. "
34+
f"Switch to an affected repo first, or close & respawn with "
35+
f"`--repos {t.active_repo},...` to make it editable."
36+
)
37+
raise typer.Exit(code=1)
38+
3139
if t.blocked_reason and not force:
3240
output.error(
3341
f"Task is blocked: {t.blocked_reason}. "

src/mship/cli/switch.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ def switch(
3636
target = t.active_repo
3737
is_switch = False
3838
else:
39-
if repo not in t.affected_repos:
40-
valid = ", ".join(t.affected_repos)
39+
all_repos = list(t.affected_repos) + [r for r in t.passive_repos if r not in t.affected_repos]
40+
if repo not in all_repos:
41+
valid = ", ".join(all_repos)
4142
output.error(f"Unknown repo '{repo}'. Valid: {valid}.")
4243
raise typer.Exit(code=1)
4344
target = repo
@@ -63,10 +64,13 @@ def _apply(s):
6364

6465
handoff = build_handoff(config, state_mgr.load(), shell, log_mgr, repo=target, task_slug=t.slug)
6566

67+
is_passive = target in t.passive_repos
68+
6669
if not output.is_tty:
6770
json_payload = handoff.to_json()
6871
json_payload["resolved_task"] = resolved.task.slug
6972
json_payload["resolution_source"] = resolved.source
73+
json_payload["is_passive"] = is_passive
7074
output.json(json_payload)
7175
return
7276

@@ -104,6 +108,15 @@ def _apply(s):
104108
lines.append(
105109
f"[bold]{verb}:[/bold] {handoff.repo} (task: {handoff.task_slug}, phase: {handoff.phase})"
106110
)
111+
if is_passive:
112+
repo_cfg = config.repos.get(target)
113+
ref = (repo_cfg.expected_branch or repo_cfg.base_branch) if repo_cfg else target
114+
lines.append(
115+
f"[yellow]⚠[/yellow] Switched to `{target}` (passive — read-only on `{ref}`)."
116+
)
117+
lines.append(
118+
f" To edit, close this task and respawn with `--repos {target},...`"
119+
)
107120
lines.append(f"[bold]Branch:[/bold] {handoff.branch}")
108121
lines.append(f"[bold]Worktree:[/bold] {handoff.worktree_path}")
109122
lines.append("")

src/mship/cli/sync.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ def register(app: typer.Typer, get_container):
99
@app.command()
1010
def sync(
1111
repos: Optional[str] = typer.Option(None, "--repos", help="Comma-separated repo names"),
12+
no_passive: bool = typer.Option(
13+
False, "--no-passive",
14+
help="Skip refreshing passive worktrees (default: include).",
15+
),
1216
):
1317
"""Fast-forward repos that audit cleanly and are behind origin."""
1418
from mship.core.repo_state import audit_repos
@@ -46,4 +50,15 @@ def sync(
4650
else:
4751
output.print(f" [yellow]{r.name}[/yellow]: skipped ({r.message})")
4852

53+
if not no_passive:
54+
from mship.core.repo_sync import refresh_passive_worktrees
55+
passive_results = refresh_passive_worktrees(
56+
container.state_manager(), config,
57+
)
58+
for r in passive_results:
59+
if r.status == "fast_forwarded":
60+
output.print(f" [green]{r.name}[/green]: {r.message}")
61+
else:
62+
output.print(f" [yellow]{r.name}[/yellow]: skipped ({r.message})")
63+
4964
raise typer.Exit(code=1 if out.has_errors else 0)

0 commit comments

Comments
 (0)