diff --git a/scripts/work-bundle/control_plane.py b/scripts/work-bundle/control_plane.py index eede645..b0f5fff 100644 --- a/scripts/work-bundle/control_plane.py +++ b/scripts/work-bundle/control_plane.py @@ -293,6 +293,23 @@ def _git(path: Path, *args: str) -> str: return result.stdout.strip() if result.returncode == 0 else "" +def _git_checkout_observation(path: Path) -> dict[str, str] | None: + resolved = path.expanduser().resolve() + top_level = _git(resolved, "rev-parse", "--show-toplevel") + if not top_level or Path(top_level).expanduser().resolve() != resolved: + return None + branch = _git(resolved, "branch", "--show-current") + head = _git(resolved, "rev-parse", "HEAD") + common_dir = _git(resolved, "rev-parse", "--git-common-dir") + if not branch or not head or not common_dir: + raise ControlPlaneError("WB_CONTROL_PLANE_BOUND_GIT_INVALID") + return { + "observed_branch": branch, + "observed_head": head, + "git_common_dir": common_dir, + } + + def _git_remote(path: Path) -> str: return validated_remote(_git(path, "remote", "get-url", "origin")) @@ -2186,6 +2203,7 @@ def rollback_attach() -> None: owned_member_paths.append(candidate) state = "absent" if candidate is not None and candidate.exists(): + manual_observation = _git_checkout_observation(candidate) if manual_locator else None actual_remote = "" if manual_locator else _resolved_git_remote(candidate) if not manual_locator and actual_remote != canonical_remote(remote): raise ControlPlaneError( @@ -2214,12 +2232,24 @@ def rollback_attach() -> None: ) ) ), - "observed_branch": "" if manual_locator else _git(candidate, "branch", "--show-current"), - "observed_head": "" if manual_locator else _git(candidate, "rev-parse", "HEAD"), + "observed_branch": ( + manual_observation["observed_branch"] + if manual_observation is not None + else ("" if manual_locator else _git(candidate, "branch", "--show-current")) + ), + "observed_head": ( + manual_observation["observed_head"] + if manual_observation is not None + else ("" if manual_locator else _git(candidate, "rev-parse", "HEAD")) + ), "observed_at": utc_now_rfc3339(), - "git_common_dir": "" if manual_locator else _git(candidate, "rev-parse", "--git-common-dir"), + "git_common_dir": ( + manual_observation["git_common_dir"] + if manual_observation is not None + else ("" if manual_locator else _git(candidate, "rev-parse", "--git-common-dir")) + ), } - if not manual_locator: + if not manual_locator or manual_observation is not None: readiness_failures.extend( _repository_execution_issues(candidate, str(repository.get("default_branch") or ""), repository_id) ) @@ -2365,6 +2395,21 @@ def cmd_doctor_workspace(args: list[str], *, command_name: str = "doctor-workspa bindings = _registry_bindings() binding = bindings.get(workspace_id) local_failures: list[str] = [] + if parsed.repair and not portable_failures: + try: + _, code = _attach( + workspace_root, + "none", + {}, + True, + create_script_index=False, + ) + except ControlPlaneError as exc: + local_failures.append(exc.code) + code = 1 + if code == 0: + bindings = _registry_bindings() + binding = bindings.get(workspace_id) if not binding: local_failures.append("WB_CONTROL_PLANE_BINDING_MISSING") elif Path(str(binding.get("workspace_root") or "")).resolve() != workspace_root: @@ -2401,6 +2446,35 @@ def cmd_doctor_workspace(args: list[str], *, command_name: str = "doctor-workspa if repo.get("required"): missing_required.append(repository_id) continue + if repo.get("locator_type") == "manual": + try: + observation = _git_checkout_observation(project_path) + except ControlPlaneError as exc: + local_failures.append(f"{exc.code}:{repository_id}") + if repo.get("required"): + missing_required.append(f"{exc.code}:{repository_id}") + continue + if observation is None: + continue + if ( + not local.get("observed_branch") + or not local.get("observed_head") + or not local.get("git_common_dir") + ): + missing_required.append(f"WB_REPOSITORY_OBSERVATION_MISSING:{repository_id}") + elif ( + str(local.get("observed_branch")) != observation["observed_branch"] + or str(local.get("observed_head")) != observation["observed_head"] + or str(local.get("git_common_dir")) != observation["git_common_dir"] + ): + missing_required.append(f"WB_REPOSITORY_OBSERVATION_STALE:{repository_id}") + readiness_issues = _repository_execution_issues( + project_path, str(repo.get("default_branch") or ""), repository_id + ) + for issue in readiness_issues: + if issue not in missing_required: + missing_required.append(issue) + continue if repo.get("locator_type") != "manual": try: actual_remote = _resolved_git_remote(project_path) @@ -2419,23 +2493,6 @@ def cmd_doctor_workspace(args: list[str], *, command_name: str = "doctor-workspa for issue in readiness_issues: if issue not in missing_required: missing_required.append(issue) - if parsed.repair and not portable_failures: - try: - result, code = _attach( - workspace_root, - "none", - {}, - True, - create_script_index=False, - ) - except ControlPlaneError as exc: - local_failures.append(exc.code) - result, code = {"status": "issues-found"}, 1 - if code == 0: - bindings = _registry_bindings() - binding = bindings.get(workspace_id) - if binding: - local_failures = [item for item in local_failures if item != "WB_CONTROL_PLANE_BINDING_MISSING"] status = "passed" if not portable_failures and not local_failures else "issues-found" out( { diff --git a/tests/test_control_plane_v4.py b/tests/test_control_plane_v4.py index ce7adb6..c5fe48f 100644 --- a/tests/test_control_plane_v4.py +++ b/tests/test_control_plane_v4.py @@ -1491,6 +1491,104 @@ def test_non_git_v3_member_migrates_with_manual_locator(tmp_path: Path) -> None: assert run_wb(config, "doctor-workspace", str(workspace)).returncode == 0 +def test_manual_git_member_attach_and_doctor_repair_publish_observations(tmp_path: Path) -> None: + config = config_root(tmp_path / "config-root") + workspace = tmp_path / "workspace" + local = workspace / "manual-main" + local.mkdir(parents=True) + control = workspace / ".work-bundle" + control.mkdir(parents=True) + (control / "project.yaml").write_text( + "\n".join([ + "metadata_version: 3", f"workspace_root: {workspace}", "workspace_mode: multi-repository", + f"project_root: {local}", "source_repositories:", " - id: manual-main", + f" project_root: {local}", " origin_id: manual-main", " git_repository: false", + ' remote: ""', " checkout_kind: local-project", "", + ]), encoding="utf-8" + ) + proposal = run_wb(config, "migrate-control-plane", str(workspace), "--dry-run") + assert proposal.returncode == 0, proposal.stdout + proposal.stderr + proposal_id = json.loads(proposal.stdout)["migration"]["proposal_id"] + applied = run_wb( + config, + "migrate-control-plane", + str(workspace), + "--apply", + "--accepted-proposal-id", + proposal_id, + ) + assert applied.returncode == 0, applied.stdout + applied.stderr + portable = yaml.safe_load((control / "project.yaml").read_text(encoding="utf-8")) + workspace_id = portable["workspace"]["id"] + default_branch = portable["source_repositories"][0]["default_branch"] + + subprocess.run(["git", "init", "-q", "-b", default_branch, str(local)], check=True) + git(local, "config", "user.email", "test@example.com") + git(local, "config", "user.name", "Test") + (local / "README.md").write_text("# manual Git member\n", encoding="utf-8") + git(local, "add", "README.md") + git(local, "commit", "-q", "-m", "init") + head = git(local, "rev-parse", "HEAD") + + attached = run_wb( + config, + "attach-workspace", + str(workspace), + "--repository-path", + f"manual-main={local}", + "--materialize", + "none", + "--apply", + ) + assert attached.returncode == 0, attached.stdout + attached.stderr + registry = yaml.safe_load((config / "registry/projects.yaml").read_text(encoding="utf-8")) + observation = registry["device_bindings"][workspace_id]["repositories"]["manual-main"] + assert observation["checkout_kind"] == "manual" + assert observation["observed_branch"] == default_branch + assert observation["observed_head"] == head + assert observation["git_common_dir"] + + preflight = run_orch( + config, + "--workspace-root", + str(workspace), + "repository-preflight", + "--repository", + str(local), + ) + assert preflight.returncode == 0, preflight.stdout + preflight.stderr + row = json.loads(preflight.stdout)["repository_preflight"]["repositories"][0] + assert row["status"] == "clean" + + observation["git_common_dir"] = "" + (config / "registry/projects.yaml").write_text( + yaml.safe_dump(registry, sort_keys=False), encoding="utf-8" + ) + doctor = run_wb(config, "doctor-workspace", str(workspace)) + assert doctor.returncode == 0, doctor.stdout + doctor.stderr + doctor_data = json.loads(doctor.stdout) + assert doctor_data["execution_readiness"]["status"] == "not-ready" + assert "WB_REPOSITORY_OBSERVATION_MISSING:manual-main" in doctor_data["execution_readiness"]["execution_readiness_failures"] + + observation["git_common_dir"] = "/stale/git/common-dir" + (config / "registry/projects.yaml").write_text( + yaml.safe_dump(registry, sort_keys=False), encoding="utf-8" + ) + doctor = run_wb(config, "doctor-workspace", str(workspace)) + assert doctor.returncode == 0, doctor.stdout + doctor.stderr + doctor_data = json.loads(doctor.stdout) + assert doctor_data["execution_readiness"]["status"] == "not-ready" + assert "WB_REPOSITORY_OBSERVATION_STALE:manual-main" in doctor_data["execution_readiness"]["execution_readiness_failures"] + + repaired = run_wb(config, "doctor-workspace", str(workspace), "--repair") + assert repaired.returncode == 0, repaired.stdout + repaired.stderr + repaired_registry = yaml.safe_load((config / "registry/projects.yaml").read_text(encoding="utf-8")) + repaired_observation = repaired_registry["device_bindings"][workspace_id]["repositories"]["manual-main"] + assert repaired_observation["observed_branch"] == default_branch + assert repaired_observation["observed_head"] == head + assert repaired_observation["git_common_dir"] + + def test_existing_checkout_credential_remote_is_rejected_without_echo(tmp_path: Path) -> None: config = config_root(tmp_path / "config-root") workspace, remote, _ = make_v3_workspace(tmp_path / "fixture")