Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
- Off by default (`[scm] isolation = "none"` — work in place on the checked-out branch, byte-for-byte the prior behavior). Set `isolation = "worktree"` and each story (and each sweep bundle) runs in its own `git worktree` on a `bmad-loop/<run_id>[/<story>]` branch cut from the target branch, then merges back **locally** — the main checkout stays free while a run is in flight.
- Merge knobs: `merge_strategy` (`ff` / `merge` / `squash`), `target_branch` (default = branch checked out at run start; created if missing — a detached HEAD or unborn repo pauses the run instead of merging onto an unreferenced commit), `branch_per` (`story` or a shared `run` branch; `run` forces `delete_branch = false`), and `delete_branch`.
- Failed-unit forensics: a deferred/escalated unit's worktree + branch stay mounted (`keep_failed`, default on) and its full diff is preserved to `run_dir/failed/<unit>/changes.patch`; `failed_diff_max_mb` caps per-file untracked-file size (oversized skipped with a marker), `failed_diff_unlimited` lifts the cap.
- Config seeding: a worktree checks out _tracked_ files only, so a project's gitignored MCP/CLI configs (`.mcp.json`, `.claude/settings.json`, `.codex/config.toml`, `.gemini/settings.json`) would be missing — an isolated session couldn't reach its MCP server. With `seed_adapter_defaults` (default on) each loaded adapter's own `seed_files` are copied in from the main repo before the session launches; `worktree_seed` adds extra paths. Copy-when-absent, seeded before the hook-merge (a seeded `settings.json` keeps its content and just gains the Stop hook), and shielded from the unit's `git add -A`.
- Config seeding: a worktree checks out _tracked_ files only, so a project's gitignored MCP/CLI configs (`.mcp.json`, `.claude/settings.json`, `.codex/config.toml`, `.gemini/settings.json`) would be missing — an isolated session couldn't reach its MCP server. With `seed_adapter_defaults` (default on) each loaded adapter's own `seed_files` are copied in from the main repo before the session launches; `worktree_seed` adds extra paths. Copy-when-absent at file granularity — a directory entry whose destination already exists (a worktree checkout carries its tracked children) still seeds the children that are missing — seeded before the hook-merge (a seeded `settings.json` keeps its content and just gains the Stop hook), and shielded from the unit's `git add -A`.
- Run state never moves into a worktree — `.bmad-loop/` always lives in the main repo; spec paths are persisted relative to the worktree so a kept-failed run stays portable.
- Merge-back is serialized; `max_parallel` is a validated knob clamped to `1` until parallel fan-out is built. The `repo_root` key in `_bmad/bmm/config.yaml` (defaults to the project dir) decouples where git/code work happens from where run state lives (monorepos).
- `commit_message_template` (`{story_key}` / `{run_id}` substituted) customizes story/bundle commit messages.
Expand Down
32 changes: 29 additions & 3 deletions src/bmad_loop/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,23 +384,49 @@ def _register_hooks(project: Path, profile: CLIProfile) -> int:
return 0


def _copy_traversable(src, dst: Path) -> None:
def _copy_traversable(src, dst: Path, *, skip_existing: bool = False) -> bool:
"""Recursively copy a packaged resource tree to a filesystem path.

Walks via the Traversable API (.iterdir/.read_bytes) rather than resolving a
filesystem path, so it works even when the package is zip-imported.

``skip_existing`` makes the copy no-clobber at FILE granularity: an existing
destination file is left untouched and its siblings are still copied — even
when it stands where the source has a directory (that subtree is skipped
whole rather than mkdir'd over the file). It is
opt-in because the `--force-skills` path (`install_into`) rmtree's the
destination precisely to overwrite it, and a guard baked into this helper
would silently regress that. Only the worktree-seed caller passes it.

Returns whether anything was actually written, so a caller seeding into an
existing directory can tell a partial seed (something landed) from a total
no-op (every child was already present). Every other call site ignores it.
"""
if src.is_dir():
if skip_existing and dst.exists() and not dst.is_dir():
# a FILE sits where the source has a directory: mkdir would raise
# FileExistsError. Under the no-clobber contract the file wins and
# the whole subtree is skipped, like any existing destination.
return False
# creating a missing directory IS a write: an empty dir seeded into an
# existing tree must count, or the entry is misreported as a total no-op.
copied = not dst.exists()
dst.mkdir(parents=True, exist_ok=True)
# `any(...)` would short-circuit and skip the remaining children.
for child in src.iterdir():
_copy_traversable(child, dst / child.name)
elif isinstance(src, Path):
if _copy_traversable(child, dst / child.name, skip_existing=skip_existing):
copied = True
return copied
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if skip_existing and dst.exists():
return False
if isinstance(src, Path):
# real filesystem source (worktree seeds, non-zip package data): copy2
# preserves the mode so a seeded vendor/bin/* keeps +x (issue #126)
shutil.copy2(src, dst)
else:
# zip-imported Traversable exposes no stat: content-only copy
dst.write_bytes(src.read_bytes())
return True


def _worktree_local_exclude(worktree: Path, patterns: Sequence[str]) -> None:
Expand Down
56 changes: 41 additions & 15 deletions src/bmad_loop/worktree_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,15 @@ def provision_worktree(
to pull its MCP-generated skill tree (gitignored, so absent from the checkout)
into a per_worktree Editor's checkout.

A `seed_files` entry naming a DIRECTORY whose destination already exists is
seeded child by child: the children the checkout lacks are copied in, the ones
it carries are left untouched. A worktree checks out tracked files, so such a
dir always exists and the entry would otherwise be a total no-op (issue #230).

Kept safe against the unit's eventual `git add -A` commit:
- skills + seed files are copied only when ABSENT, so a project that commits its
own skill tree (e.g. .agents/) or config keeps it untouched (no diff merged back);
- skills + seed files are copied only when ABSENT — at FILE granularity, so a
project that commits its own skill tree (e.g. .agents/) or config keeps it
untouched (no diff merged back);
- the hook points at the MAIN repo's already-installed relay via an absolute
path (the relay locates the run dir from $BMAD_LOOP_RUN_DIR, not its own
location), so nothing is written into the worktree's .bmad-loop/;
Expand All @@ -105,10 +111,11 @@ def provision_worktree(
also a hook config_path (.claude/settings.json, .gemini/settings.json) keeps its
real content and just gets the Stop hook merged in, rather than being created empty.

Returns the `seed_files` entries that existed in the repo but were skipped
because the destination already existed — copy-when-absent turned them into
no-ops. The caller journals them: a user-authored `worktree_seed` entry that
silently copies nothing reads as applied configuration and is not.
Returns the `seed_files` entries that copied NOTHING because everything they
name was already present — copy-when-absent turned them into no-ops. The caller
journals them: a user-authored `worktree_seed` entry that silently copies
nothing reads as applied configuration and is not. A directory entry that
seeded even one child is not reported: it is applied configuration.
"""
if not profiles and not seed_files and not seed_globs:
return []
Expand All @@ -120,14 +127,13 @@ def provision_worktree(
# project gitignored MCP/CLI configs: copy from the main repo when absent.
# Resolve-and-contain guards against an `..`/absolute entry escaping either tree.
seeded: list[str] = []
# Entries that named a real source but whose destination already exists, so
# copy-when-absent made them a no-op. Reported to the caller (this function is
# quiet by contract — it runs under a TUI) because for a DIRECTORY entry the
# no-op is silent and total: a worktree checks out tracked files, so a seed dir
# with any tracked child always exists and the whole entry is skipped, including
# the children that are absent and would clobber nothing. Glob-expanded matches
# are deliberately not reported: a plugin's glob is expected to hit paths the
# checkout already carries, so that skip is routine rather than a misconfiguration.
# Entries that named a real source but copied nothing, because every path they
# name already exists. Reported to the caller (this function is quiet by
# contract — it runs under a TUI) because the no-op is otherwise silent: an
# entry that reads as applied configuration is not. Per-CHILD skips inside a
# directory entry are deliberately not reported — the checkout is expected to
# carry its tracked children, so that is routine rather than a
# misconfiguration, exactly like the glob-expanded matches below.
skipped: list[str] = []
for rel in seed_files:
src = (repo_root / rel).resolve()
Expand All @@ -137,7 +143,27 @@ def provision_worktree(
if not src.exists():
continue
if dst.exists():
skipped.append(str(rel))
# File entries keep the classic copy-when-absent skip. A destination
# that is not a directory while the source is (the checkout carries a
# FILE where the seed names a dir) is a type mismatch: recursing would
# try to mkdir over the file, so the entry is skipped whole instead.
if not src.is_dir() or not dst.is_dir():
skipped.append(str(rel))
continue
# A DIRECTORY whose destination exists is the case #230 reported: the
# checkout carries some tracked child, so the whole entry used to be a
# no-op — including the gitignored children that are absent and would
# clobber nothing. Recurse instead, copying only what is missing.
if not _copy_traversable(src, dst, skip_existing=True):
# every child was already present: still a total no-op, still
# reported. Only a PARTIAL seed stops being reported.
skipped.append(str(rel))
continue
# Partially seeded, so the entry must still reach `patterns` below:
# the children we just wrote have to stay out of the unit's
# `git add -A`. Excluding the whole dir is safe — an exclude does not
# untrack the tracked children that were already there.
seeded.append(rel)
continue
dst.parent.mkdir(parents=True, exist_ok=True)
_copy_traversable(src, dst)
Expand Down
Loading