diff --git a/README.md b/README.md index 475d8bd..1780143 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,22 @@ Both single- and multi-repository workspaces are current. Multi-repository membe Portable control-plane v4 keeps the single-repository layout flat: the source repository owns `/.git`, while the independently publishable WorkBundle control plane owns `/.work-bundle/.git`. Its source entry uses `workspace_binding.type: root`; multi-repository entries use `workspace_binding.type: member` plus a member name. A fresh device clones the control plane into `.work-bundle/`, then `attach-workspace --materialize missing --apply` reconstructs the source checkout directly in the existing workspace root. It never requires converting a single repository into a child of a non-Git container. +To add another source to an initialized v4 multi-repository workspace, use the +proposal-bound lifecycle (the direct member name and path must agree): + +```bash +python3 scripts/wb.py add-workspace-member \ + --repository-id --remote --name --path \ + --default-branch main --dry-run +# Repeat the same request with --accepted-proposal-id --apply. +``` + +This preserves multi-repository mode and registers a verified existing checkout +or a new clone without creating a root Git repository. The checkout and Git +common directory stay inside the workspace. Replay verifies the local binding; +failed publication preserves adopted checkouts and removes only newly created +ones. Single/composite workspaces retain their root-source and exclusion behavior. + ## Skill Links Install bootstrap/registry and symlink all work-bundle skills into the shared agent skill root: diff --git a/references/evals/work-bundle/evals.json b/references/evals/work-bundle/evals.json index 6e60981..b8416a1 100644 --- a/references/evals/work-bundle/evals.json +++ b/references/evals/work-bundle/evals.json @@ -42,6 +42,18 @@ "prompt": "Attach and doctor a portable v4 multi-repository workspace when bootstrap points project_registry to a non-default path and a later attach step fails after creating member checkouts.", "expected_output": "Uses project.yaml only for portable identity and topology, uses device_bindings in the bootstrap-resolved project registry for local paths and observations across attach, doctor, and preflight, and removes only member paths created by the failed attach while preserving user-supplied checkouts and the control plane.", "files": [] + }, + { + "id": 8, + "prompt": "Add an already cloned private source repository on main to an initialized v4 multi-repository workspace whose root is not a Git repository. Do not hand-edit membership.", + "expected_output": "Uses add-workspace-member dry-run and accepted-proposal apply with matching direct member name/path. Preserves multi-repository mode, validates existing required members and local Git-store containment, adopts the checkout without claiming rollback ownership, and verifies managed-worktree binding, replay and doctor. Does not create root .git or composite exclusions.", + "files": [] + }, + { + "id": 9, + "prompt": "Add a member to a v4 multi-repository workspace but the requested path is a symlink, credentials, or a linked worktree whose Git common directory is outside the workspace. The user says just force it.", + "expected_output": "Rejects the unsafe member through the lifecycle without cloning, publishing metadata, changing mode, overwriting user files or treating a force request as permission to bypass containment. Keeps the existing composite member workflow distinct.", + "files": [] } ] } diff --git a/scripts/work-bundle/control_plane.py b/scripts/work-bundle/control_plane.py index 5ceb67a..9a9a7f0 100644 --- a/scripts/work-bundle/control_plane.py +++ b/scripts/work-bundle/control_plane.py @@ -1554,7 +1554,7 @@ def _classify_workspace_member( repository_id = str(repository.get("id") or "") binding_type = str(repository.get("workspace_binding_type") or "") name = str(repository.get("workspace_binding_name") or "") - path = str(repository.get("workspace_binding_path") or "") + path = _member_segment(repository, name) if binding_type == "member" else "" remote = str(repository.get("canonical_remote") or "") branch = str(repository.get("default_branch") or "") same_id = repository_id == member["repository_id"] @@ -1574,7 +1574,7 @@ def _classify_workspace_member( return "absent" -def _render_member_metadata_block(member: dict[str, str]) -> str: +def _render_member_metadata_block(member: dict[str, str], *, multi: bool = False) -> str: return "\n".join( [ f" - id: {_quote(member['repository_id'])}", @@ -1586,7 +1586,7 @@ def _render_member_metadata_block(member: dict[str, str]) -> str: " workspace_binding:", " type: member", f" name: {_quote(member['name'])}", - f" path: {_quote(member['path'])}", + *([] if multi else [f" path: {_quote(member['path'])}"]), " materialization:", " required: true", " operation_policy: inherit", @@ -1597,10 +1597,12 @@ def _render_member_metadata_block(member: dict[str, str]) -> str: def _append_member_metadata(text: str, member: dict[str, str]) -> str: if _workspace_value(text, "mode") == "single-repository": text = re.sub(r"^(\s{2}mode: )single-repository\s*$", r"\1composite", text, count=1, flags=re.MULTILINE) - block = _render_member_metadata_block(member) - if "prefer_subagent:" in text: - return text.replace("prefer_subagent:", block + "prefer_subagent:", 1) - return text.rstrip() + "\n" + block + block = _render_member_metadata_block(member, multi=_workspace_value(text, "mode") == "multi-repository") + lines = text.splitlines(keepends=True) + start = next(i for i, line in enumerate(lines) if line.rstrip() == "source_repositories:") + end = next((i for i in range(start + 1, len(lines)) if re.match(r"^[A-Za-z_][\w-]*:", lines[i])), len(lines)) + prefix = "".join(lines[:end]) + return prefix.rstrip("\n") + "\n" + block + "".join(lines[end:]) def _require_observed_branch(path: Path, expected: str, repository_id: str) -> str: @@ -1618,6 +1620,31 @@ def _add_workspace_member_preflight(workspace_root: Path, text: str) -> dict[str bound_root = str(binding.get("workspace_root") or "") if not bound_root or Path(bound_root).expanduser().resolve() != workspace_root: raise ControlPlaneError("WB_CONTROL_PLANE_BINDING_ROOT_MISMATCH") + if _workspace_value(text, "mode") == "multi-repository": + if (workspace_root / ".git").exists(): + raise ControlPlaneError("WB_CONTROL_PLANE_SINGLE_REPOSITORY_TOPOLOGY_UNRESOLVED") + repositories = binding.get("repositories") + for repo in _v4_repositories(text): + repository_id = str(repo["id"]) + local = repositories.get(repository_id) if isinstance(repositories, dict) else None + if not isinstance(local, dict) or not local.get("project_root"): + if repo.get("required"): + raise ControlPlaneError(f"WB_CONTROL_PLANE_BOUND_CHECKOUT_MISSING:{repository_id}") + continue + path = Path(str(local["project_root"])).expanduser() + expected = workspace_root / _member_segment(repo, str(repo.get("workspace_binding_name") or repository_id)) + if path.resolve() != expected or path.is_symlink(): + raise ControlPlaneError(f"WB_CONTROL_PLANE_MEMBER_DEVICE_BINDING_MISMATCH:{repository_id}") + if repo.get("locator_type") == "manual": + if not path.is_dir(): + raise ControlPlaneError(f"WB_CONTROL_PLANE_BOUND_CHECKOUT_MISSING:{repository_id}") + continue + _require_multi_member_checkout(workspace_root, path, { + "repository_id": repository_id, + "remote": str(repo.get("canonical_remote") or ""), + "default_branch": str(repo.get("default_branch") or ""), + }) + return binding root = next( (item for item in _v4_repositories(text) if str(item.get("workspace_binding_type") or "") == "root"), None, @@ -1665,7 +1692,7 @@ def _require_add_workspace_member_target(text: str, member: dict[str, str], clas def _require_add_workspace_member_replay_state( - workspace_root: Path, member: dict[str, str], binding: dict[str, object] + workspace_root: Path, member: dict[str, str], binding: dict[str, object], *, multi: bool = False ) -> None: member_path = (workspace_root / member["path"]).resolve() if not member_path.is_dir() or not (member_path / ".git").exists(): @@ -1679,8 +1706,12 @@ def _require_add_workspace_member_replay_state( if not isinstance(local, dict) or not local.get("project_root"): raise ControlPlaneError(f"WB_CONTROL_PLANE_MEMBER_DEVICE_BINDING_MISSING:{member['repository_id']}") bound_path = Path(str(local["project_root"])).expanduser().resolve() - if bound_path != member_path or str(local.get("checkout_kind") or "") != "nested-member": + expected_kind = "managed-worktree" if multi else "nested-member" + if bound_path != member_path or str(local.get("checkout_kind") or "") != expected_kind: raise ControlPlaneError(f"WB_CONTROL_PLANE_MEMBER_DEVICE_BINDING_MISMATCH:{member['repository_id']}") + if multi: + _require_multi_member_checkout(workspace_root, workspace_root / member["path"], member) + return exclude_lines = {line.strip() for line in read(workspace_root / ".git/info/exclude").splitlines()} if f"{member['path'].rstrip('/')}/" not in exclude_lines: raise ControlPlaneError(f"WB_CONTROL_PLANE_MEMBER_EXCLUDE_MISSING:{member['path']}") @@ -1695,6 +1726,22 @@ def _inspect_existing_member_checkout(member_path: Path, member: dict[str, str]) _require_observed_branch(member_path, member["default_branch"], member["repository_id"]) +def _require_multi_member_checkout(workspace_root: Path, path: Path, member: dict[str, str]) -> None: + """A multi-repository member and its Git store must stay inside the workspace.""" + repository_id = member["repository_id"] + if path.is_symlink() or path.resolve().parent != workspace_root: + raise ControlPlaneError(f"WB_CONTROL_PLANE_MATERIALIZATION_PATH_INVALID:{repository_id}") + if not path.is_dir() or not (path / ".git").exists(): + raise ControlPlaneError(f"WB_CONTROL_PLANE_BOUND_CHECKOUT_MISSING:{repository_id}") + common = _git(path, "rev-parse", "--path-format=absolute", "--git-common-dir") + if not common or not Path(common).resolve().is_relative_to(workspace_root): + raise ControlPlaneError(f"WB_CONTROL_PLANE_BOUND_GIT_INVALID:{repository_id}") + _inspect_existing_member_checkout(path, member) + issues = _repository_execution_issues(path, member["default_branch"], repository_id) + if issues: + raise ControlPlaneError(issues[0]) + + def _materialize_member_checkout(member_path: Path, member: dict[str, str]) -> None: _materialize(member["remote"], member_path) current_branch = _git(member_path, "branch", "--show-current") @@ -1714,19 +1761,20 @@ def _add_workspace_member_proposal( (item for item in repositories if str(item.get("workspace_binding_type") or "") == "root"), {}, ) + multi = _workspace_value(text, "mode") == "multi-repository" facts = { "current_mode": _workspace_value(text, "mode"), - "target_mode": "composite", + "target_mode": "multi-repository" if multi else "composite", "root": { "workspace_id": _workspace_id(text), "repository_id": str(root.get("id") or ""), }, "member": dict(member), - "exclude_patterns": [f"{member['path'].rstrip('/')}/"], + "exclude_patterns": [] if multi else [f"{member['path'].rstrip('/')}/"], "device_binding_delta": { "repository_id": member["repository_id"], "project_root": str(workspace_root / member["path"]), - "checkout_kind": "nested-member", + "checkout_kind": "managed-worktree" if multi else "nested-member", }, "metadata_digest": _metadata_digest(text), } @@ -2212,6 +2260,7 @@ def _apply_add_workspace_member( registry = resolve_project_registry_path() member_path = workspace_root / member["path"] workspace_id = _workspace_id(text) + multi = _workspace_value(text, "mode") == "multi-repository" owned_member = False try: _add_workspace_member_preflight(workspace_root, text) @@ -2220,12 +2269,12 @@ def _apply_add_workspace_member( else: owned_member = True _materialize_member_checkout(member_path, member) + if multi: + _require_multi_member_checkout(workspace_root, member_path, member) rendered = _append_member_metadata(text, member) portable = _portable_failures(rendered) if portable: raise ControlPlaneError(portable[0]) - members = _composite_members(rendered) - exclude_text = _exclude_text_with_source_and_members(read(exclude_path), members) bindings = _registry_bindings() existing = bindings.get(workspace_id, {}) if not isinstance(existing, dict): @@ -2236,23 +2285,23 @@ def _apply_add_workspace_member( local_repositories[member["repository_id"]] = { **(current_binding if isinstance(current_binding, dict) else {}), "project_root": str(member_path.resolve()), - "checkout_kind": "nested-member", + "checkout_kind": "managed-worktree" if multi else "nested-member", "observed_branch": _git(member_path, "branch", "--show-current"), "observed_head": _git(member_path, "rev-parse", "HEAD"), "observed_at": utc_now_rfc3339(), - "git_common_dir": _git(member_path, "rev-parse", "--git-common-dir"), + "git_common_dir": _git(member_path, "rev-parse", "--path-format=absolute", "--git-common-dir"), } bindings[workspace_id] = { **existing, "repositories": local_repositories, } - changed = _atomic_publish( - { - metadata_path: rendered, - exclude_path: exclude_text, - registry: _bindings_document(bindings, read(registry) or "projects: []\n"), - } - ) + writes = { + metadata_path: rendered, + registry: _bindings_document(bindings, read(registry) or "projects: []\n"), + } + if not multi: + writes[exclude_path] = _exclude_text_with_source_and_members(read(exclude_path), _composite_members(rendered)) + changed = _atomic_publish(writes) return { "status": "passed", "dry_run": False, @@ -2305,8 +2354,13 @@ def cmd_add_workspace_member(args: list[str]) -> int: metadata_path = workspace_root / ".work-bundle/project.yaml" text = read(metadata_path) mode = _workspace_value(text, "mode") - if mode not in {"single-repository", "composite"}: + if mode not in {"single-repository", "composite", "multi-repository"}: raise ControlPlaneError("WB_CONTROL_PLANE_COMPOSITE_SOURCE_MODE_INVALID") + multi = mode == "multi-repository" + if multi and name != path: + raise ControlPlaneError("WB_CONTROL_PLANE_MEMBER_BINDING_INVALID") + if multi and path in {".git", "script", "credentials"}: + raise ControlPlaneError("WB_CONTROL_PLANE_MEMBER_PATH_INVALID") portable = _portable_failures(text) if portable: raise ControlPlaneError(portable[0]) @@ -2316,6 +2370,8 @@ def cmd_add_workspace_member(args: list[str]) -> int: member_path = workspace_root / path if member_path.exists() or member_path.is_symlink(): _inspect_existing_member_checkout(member_path, member) + if multi: + _require_multi_member_checkout(workspace_root, member_path, member) classification = _classify_workspace_member(_v4_repositories(text), member) if classification == "collision": raise ControlPlaneError("WB_CONTROL_PLANE_MEMBER_COLLISION") @@ -2336,7 +2392,7 @@ def cmd_add_workspace_member(args: list[str]) -> int: return 1 if classification == "match": live_binding = _add_workspace_member_preflight(workspace_root, live_text) - _require_add_workspace_member_replay_state(workspace_root, member, live_binding) + _require_add_workspace_member_replay_state(workspace_root, member, live_binding, multi=multi) out({**payload, "status": "passed", "dry_run": False, "replay": True, "changed_files": []}) return 0 applied = _apply_add_workspace_member(workspace_root, live_text, member) diff --git a/skills/wb-initialize-project/SKILL.md b/skills/wb-initialize-project/SKILL.md index b94832f..9bc93ef 100644 --- a/skills/wb-initialize-project/SKILL.md +++ b/skills/wb-initialize-project/SKILL.md @@ -45,7 +45,7 @@ Invoke project lifecycle behavior only through `python3 scripts/wb.py` dispatche | Apply registry-wide layout migration | `migrate-registered-projects --apply --accepted-plan-id [--slug ]` | | Attach portable workspace | `attach-workspace [--materialize ] [--repository-path =] (--dry-run|--apply)` | | Doctor portable workspace | `doctor-workspace [--repair]` | -| Add composite member | `add-workspace-member --repository-id --remote --name --path --default-branch (--dry-run|--accepted-proposal-id --apply)` | +| Add v4 workspace member | `add-workspace-member --repository-id --remote --name --path --default-branch (--dry-run|--accepted-proposal-id --apply)` | | Provision member | `provision-member --workspace-root [--workspace-slug ] --origin --repository-id --working-branch --base-ref [--dry-run|--apply]` | | Cleanup member | `cleanup-member --workspace-root --repository-id (--dry-run|--apply)` | | Set sub-agent preference | `set-prefer-subagent --scope [--project-root ]` | @@ -68,7 +68,12 @@ For metadata v2, `migrate-project --dry-run` classifies topology from project me `provision-member --dry-run` returns `status: proposed` without writes. Apply treats checkout verification as an internal state and returns `status: passed` only after the member binding and origin locator are recoverably published to workspace metadata and the project registry. Matching verified transactions resume publication, published transactions replay without writes, and unrelated targets remain collisions. -`add-workspace-member --dry-run` returns a digest-bound proposal without writes only after the current workspace binding, matching workspace root, root repository local binding, and a valid root Git checkout are present, the live root origin and observed branch match the portable root remote/default branch, and the rendered target metadata plus required request values validate. A pre-existing member checkout must already be on `--default-branch`; apply also re-verifies the branch after a transaction-owned clone or checkout. The first accepted apply converts metadata-v4 `single-repository` to `composite` and publishes the named nested member, root-source exclusion, and device binding recoverably; later applies are add-only. Matching replay is a no-op only when the member checkout, nested-member device binding, and root exclude are already present and matched; otherwise apply fails closed without mutation and attach/doctor remain the repair path. Do not extend v3 `provision-member` for this topology. +`add-workspace-member --dry-run` validates the current workspace binding, matching workspace root, requested remote/branch and rendered portable metadata before returning a digest-bound proposal without writes. Apply requires that exact proposal and rechecks checkout state before recoverable publication. A pre-existing checkout must be on `--default-branch`; it is never rollback-owned. Newly cloned checkouts are removed if publication fails. + +- **Single/composite:** require the root source binding and valid Git checkout with matching remote/branch. The first single-repository apply converts to composite; later adds preserve composite mode. Publish the nested-member binding and owned root-source exclusion. Replay requires both binding and exclusion to match. +- **Multi-repository:** preserve the non-Git workspace root and multi-repository mode. Require a direct member with `--name` equal to `--path`; reject protected resource paths, symlinks and external Git common directories. Verify existing required members and the new checkout, including branch and cleanliness. Publish the portable member name and device-local `managed-worktree` binding without creating root `.git` or composite exclusions. Replay requires the same checkout and complete matching binding. + +Use this command for v4 membership additions, not v3 `provision-member` or manual metadata edits. Missing/inconsistent local state fails closed; explicit attach/doctor remains the repair path. An exact workspace-local checkout created by an older WorkBundle version may have no recovery record. `provision-member` adopts it only when control scope, origin, repository ID, branch, and base HEAD all match; dry-run reports `resume_source: verified-orphan`. It never claims that adopted checkout as rollback-owned. `cleanup-member` is limited to recorded, unpublished, transaction-owned checkouts; published members require a separate deregistration workflow and unrecorded paths are never deleted. diff --git a/tests/test_control_plane_v4.py b/tests/test_control_plane_v4.py index 20ef73a..73669be 100644 --- a/tests/test_control_plane_v4.py +++ b/tests/test_control_plane_v4.py @@ -2176,7 +2176,7 @@ def test_attach_and_doctor_reapply_composite_excludes_and_fail_closed_when_track failures = payload["portable"]["failures"] + payload["local_binding"]["failures"] self.assertTrue(any("WB_CONTROL_PLANE_MEMBER_PATH_TRACKED" in item for item in failures)) - def test_add_workspace_member_rejects_multi_repository_source(self) -> None: + def test_add_workspace_member_rejects_unmaterialized_required_multi_source(self) -> None: config = config_root(self.tmp_path / "config-root") remote, _, _ = make_remote(self.tmp_path / "source-fixture", "source") workspace = self.tmp_path / "multi" @@ -2196,7 +2196,7 @@ def test_add_workspace_member_rejects_multi_repository_source(self) -> None: member_remote, _, _ = make_remote(self.tmp_path / "member-fixture", "execution-flow") result = run_wb(config, *add_workspace_member_args(workspace, member_remote), "--dry-run") self.assertEqual(result.returncode, 1) - self.assertEqual(json.loads(result.stdout)["failure_code"], "WB_CONTROL_PLANE_COMPOSITE_SOURCE_MODE_INVALID") + self.assertEqual(json.loads(result.stdout)["failure_code"], "WB_CONTROL_PLANE_BOUND_CHECKOUT_MISSING:source-main") def test_add_workspace_member_preflight_rejects_absent_binding_and_non_git_root(self) -> None: config, workspace, _, workspace_id = init_single_v4(self.tmp_path) diff --git a/tests/test_multi_repository_member.py b/tests/test_multi_repository_member.py new file mode 100644 index 0000000..d698c73 --- /dev/null +++ b/tests/test_multi_repository_member.py @@ -0,0 +1,201 @@ +"""Public lifecycle regression tests for adding members to a non-Git v4 root.""" +import json +import os +from pathlib import Path +import subprocess + +import pytest +import yaml + +from test_control_plane_v4 import ( + add_workspace_member_args, config_root, git, make_remote, run_wb, +) + + +@pytest.fixture +def multi(tmp_path): + config = config_root(tmp_path) + remote, _, _ = make_remote(tmp_path, "source") + workspace = tmp_path / "workspace" + result = run_wb(config, "init-workspace", str(workspace), "--slug", "multi", + "--repository", f"source-main={remote}", "--apply") + assert result.returncode == 0, result.stdout + result.stderr + result = run_wb(config, "attach-workspace", str(workspace), "--materialize", "missing", "--apply") + assert result.returncode == 0, result.stdout + result.stderr + member_remote, _, _ = make_remote(tmp_path, "new-source") + return config, workspace, member_remote + + +def propose(config, workspace, remote, **kwargs): + result = run_wb(config, *add_workspace_member_args(workspace, remote, **kwargs), "--dry-run") + assert result.returncode == 0, result.stdout + result.stderr + return json.loads(result.stdout) + + +def apply(config, workspace, remote, proposal, **kwargs): + return run_wb(config, *add_workspace_member_args(workspace, remote, **kwargs), + "--accepted-proposal-id", proposal["proposal_id"], "--apply") + + +@pytest.mark.parametrize("adopt", [False, True]) +def test_multi_member_add_preserves_mode_and_replays_without_root_git(multi, adopt): + config, workspace, remote = multi + member = workspace / "execution-flow" + if adopt: + subprocess.run(["git", "clone", "-q", str(remote), str(member)], check=True) + metadata = workspace / ".work-bundle/project.yaml" + registry = config / "registry/projects.yaml" + original = metadata.read_text() + # Extra top-level fields must not capture the appended repository block. + original = original.replace("prefer_subagent: false\n", "") + "custom_owner_field: retained\n" + metadata.write_text(original) + before = metadata.read_bytes(), registry.read_bytes() + proposal = propose(config, workspace, remote) + assert proposal["proposal"]["target_mode"] == "multi-repository" + assert proposal["proposal"]["exclude_patterns"] == [] + assert before == (metadata.read_bytes(), registry.read_bytes()) + result = apply(config, workspace, remote, proposal) + assert result.returncode == 0, result.stdout + result.stderr + assert "mode: multi-repository" in metadata.read_text() + assert "custom_owner_field: retained" in metadata.read_text() + assert not (workspace / ".git").exists() + assert git(member, "rev-parse", "HEAD") == git(remote, "rev-parse", "HEAD") + portable = yaml.safe_load(metadata.read_text()) + assert len(portable["source_repositories"]) == 2 + assert portable["source_repositories"][1]["workspace_binding"] == {"type": "member", "name": "execution-flow"} + binding = yaml.safe_load(registry.read_text())["device_bindings"][portable["workspace"]["id"]] + assert binding["repositories"]["execution-flow"]["checkout_kind"] == "managed-worktree" + before = metadata.read_bytes(), registry.read_bytes() + replay = apply(config, workspace, remote, propose(config, workspace, remote)) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert json.loads(replay.stdout)["replay"] is True + assert json.loads(replay.stdout)["changed_files"] == [] + assert before == (metadata.read_bytes(), registry.read_bytes()) + for command in ("doctor-workspace", "attach-workspace"): + extra = ("--materialize", "none", "--apply") if command == "attach-workspace" else () + checked = run_wb(config, command, str(workspace), *extra) + assert checked.returncode == 0, checked.stdout + checked.stderr + assert not (workspace / ".git").exists() + + +@pytest.mark.parametrize("adopt", [False, True]) +def test_multi_member_failure_preserves_existing_and_removes_only_owned_checkout(multi, adopt): + config, workspace, remote = multi + member = workspace / "execution-flow" + if adopt: + subprocess.run(["git", "clone", "-q", str(remote), str(member)], check=True) + proposal = propose(config, workspace, remote) + metadata = workspace / ".work-bundle/project.yaml" + registry = config / "registry/projects.yaml" + before = metadata.read_bytes(), registry.read_bytes() + os.chmod(config / "registry", 0o555) + try: + result = apply(config, workspace, remote, proposal) + finally: + os.chmod(config / "registry", 0o755) + assert result.returncode == 1 + assert json.loads(result.stdout)["failure_code"] == "WB_CONTROL_PLANE_TRANSACTION_FAILED" + assert before == (metadata.read_bytes(), registry.read_bytes()) + assert member.exists() is adopt + assert not (workspace / ".git").exists() + + +def test_multi_member_rejects_stale_proposal_and_name_path_disagreement(multi): + config, workspace, remote = multi + proposal = propose(config, workspace, remote) + metadata = workspace / ".work-bundle/project.yaml" + metadata.write_text(metadata.read_text() + "custom_field: changed\n") + result = apply(config, workspace, remote, proposal) + assert json.loads(result.stdout)["failure_code"] == "WB_CONTROL_PLANE_PROPOSAL_STALE" + result = run_wb(config, *add_workspace_member_args(workspace, remote, path="different"), "--dry-run") + assert result.returncode == 1 + assert not (workspace / "execution-flow").exists() + + +@pytest.mark.parametrize("unsafe", ["symlink", "external-git-store", "wrong-branch", "dirty"]) +def test_multi_member_rejects_unsafe_existing_checkout_without_mutation(multi, tmp_path, unsafe): + config, workspace, remote = multi + member = workspace / "execution-flow" + external = tmp_path / "external" + if unsafe == "symlink": + subprocess.run(["git", "clone", "-q", str(remote), str(external)], check=True) + member.symlink_to(external, target_is_directory=True) + elif unsafe == "external-git-store": + subprocess.run(["git", "clone", "-q", str(remote), str(external)], check=True) + git(external, "worktree", "add", "--detach", str(member), "HEAD") + git(member, "checkout", "-b", "main-local") + else: + subprocess.run(["git", "clone", "-q", str(remote), str(member)], check=True) + if unsafe == "wrong-branch": + git(member, "checkout", "-b", "other") + else: + (member / "user.txt").write_text("preserve me") + metadata = workspace / ".work-bundle/project.yaml" + registry = config / "registry/projects.yaml" + before = metadata.read_bytes(), registry.read_bytes() + kwargs = {"default_branch": "main-local"} if unsafe == "external-git-store" else {} + result = run_wb(config, *add_workspace_member_args(workspace, remote, **kwargs), "--dry-run") + assert result.returncode == 1 + assert before == (metadata.read_bytes(), registry.read_bytes()) + assert member.exists() + assert not (workspace / ".git").exists() + + +def test_multi_member_refuses_missing_required_existing_source(multi): + config, workspace, remote = multi + (workspace / "source-main").rename(workspace / "source-moved") + result = run_wb(config, *add_workspace_member_args(workspace, remote), "--dry-run") + assert result.returncode == 1 + assert "source-main" in result.stdout + assert not (workspace / "execution-flow").exists() + + +@pytest.mark.parametrize("name", [".git", "script", "credentials", ".work-bundle", "../escape"]) +def test_multi_member_rejects_reserved_or_escaping_paths(multi, name): + config, workspace, remote = multi + result = run_wb(config, *add_workspace_member_args(workspace, remote, name=name, path=name), "--dry-run") + assert result.returncode == 1 + assert "MEMBER_PATH" in result.stdout + assert not (workspace / ".git").exists() + + +@pytest.mark.parametrize("damage", ["missing", "kind", "path"]) +def test_multi_member_replay_refuses_incomplete_binding(multi, damage): + config, workspace, remote = multi + result = apply(config, workspace, remote, propose(config, workspace, remote)) + assert result.returncode == 0, result.stdout + result.stderr + registry = config / "registry/projects.yaml" + data = yaml.safe_load(registry.read_text()) + binding = next(iter(data["device_bindings"].values()))["repositories"] + if damage == "missing": + del binding["execution-flow"] + elif damage == "kind": + binding["execution-flow"]["checkout_kind"] = "nested-member" + else: + binding["execution-flow"]["project_root"] = str(workspace / "source-main") + registry.write_text(yaml.safe_dump(data, sort_keys=False)) + before = registry.read_bytes() + dry = run_wb(config, *add_workspace_member_args(workspace, remote), "--dry-run") + if dry.returncode == 0: + result = apply(config, workspace, remote, json.loads(dry.stdout)) + else: + result = dry + assert result.returncode == 1 + assert before == registry.read_bytes() + assert not (workspace / ".git").exists() + + +def test_multi_member_add_only_and_collisions(multi, tmp_path): + config, workspace, remote = multi + result = apply(config, workspace, remote, propose(config, workspace, remote)) + assert result.returncode == 0, result.stdout + result.stderr + second, _, _ = make_remote(tmp_path, "second-source") + args = {"repository_id": "second", "name": "second", "path": "second"} + result = apply(config, workspace, second, propose(config, workspace, second, **args), **args) + assert result.returncode == 0, result.stdout + result.stderr + metadata = workspace / ".work-bundle/project.yaml" + before = metadata.read_bytes() + result = run_wb(config, *add_workspace_member_args(workspace, second), "--dry-run") + assert result.returncode == 1 + assert before == metadata.read_bytes() + assert len(yaml.safe_load(before)["source_repositories"]) == 3