diff --git a/.ai-context/COMMANDS.md b/.ai-context/COMMANDS.md index b3dfdf22..97015140 100644 --- a/.ai-context/COMMANDS.md +++ b/.ai-context/COMMANDS.md @@ -21,15 +21,19 @@ the canonical current command list. Zip bundle for manual upload or copy/paste into AI tools. - `basectl projects list` - list Base-managed projects discovered in the workspace. -- `basectl workspace ` - inspect workspace - status, checks, and diagnostics; explicitly clone expected repositories from - a manifest; or explicitly sync a local manifest from a configured canonical - source. +- `basectl workspace ` - inspect + workspace status, checks, and diagnostics; explicitly clone expected + repositories from a manifest; explicitly sync a local manifest from a + configured canonical source; or apply repo configuration across a workspace. - `workspace status`, `workspace check`, and `workspace doctor` support - `--format json`; `workspace clone` and `workspace pull` use text output. + `--format json`; `workspace clone`, `workspace pull`, and + `workspace configure` use text output. - `workspace clone` mutates repository checkouts only when invoked directly; `workspace pull` mutates only the local workspace manifest after validating the source. + - `workspace configure --dry-run` previews delegated `repo configure` calls; + without `--dry-run`, it skips missing or non-Base-managed repos, continues + after per-repo failures, and reports configured/skipped/failed counts. - `basectl repo ` - create repository baselines, clone GitHub repositories into the configured workspace, configure GitHub repository settings and default branch protection, diff --git a/.ai-context/WORKFLOWS.md b/.ai-context/WORKFLOWS.md index 298b1913..a6fe4336 100644 --- a/.ai-context/WORKFLOWS.md +++ b/.ai-context/WORKFLOWS.md @@ -27,6 +27,10 @@ Base-managed repositories should carry `.github/workflows/project-intake.yml` as the fallback for issues created outside `basectl gh issue create`. `basectl repo init` seeds it for new repositories, and `basectl repo configure` creates it when missing from older repositories. +When a shared repo or Project schema repair needs to roll across a local repo +family, use `basectl workspace configure --dry-run` first, then +`basectl workspace configure`; it delegates to the same idempotent per-repo +`repo configure` path and reports configured, skipped, and failed repos. If a repo Project has GitHub's default `View 1` instead of the standard Base views, use `basectl repo configure --replace-project` with `--repo`; Base archives the old Project and recreates it from `base-project-template`. diff --git a/README.md b/README.md index 26b9c7a6..31ce638d 100644 --- a/README.md +++ b/README.md @@ -453,6 +453,7 @@ basectl workspace status --manifest ~/work/workspace.yaml basectl workspace check basectl workspace doctor basectl workspace clone --manifest ~/work/workspace.yaml --dry-run +basectl workspace configure --dry-run ``` By default this scans `workspace.root` from `~/.base.d/config.yaml` when that @@ -461,8 +462,8 @@ directory of `BASE_HOME`, which matches the source-checkout sibling-repo layout. Use `--workspace ` to inspect a different workspace root for one command. Project list output is tab-separated as ``. `basectl projects list` and the read-only workspace status, check, and doctor -commands support `--format json` for machine-readable output. Workspace clone -and pull use text output only. Workspace status, check, and doctor are +commands support `--format json` for machine-readable output. Workspace clone, +pull, and configure use text output only. Workspace status, check, and doctor are read-only. Status reports each discovered project's manifest validity, whether the Base-managed project virtual environment is present, and the latest recorded `basectl check ` date when one exists. Check @@ -492,6 +493,16 @@ local workspace manifest explicitly. `--source ` and validates the fetched manifest before writing and never mutates project repositories. +Use `basectl workspace configure --dry-run` to preview applying +`basectl repo configure` across Base-managed repositories in the workspace, then +run `basectl workspace configure` to apply the repair path. With +`--manifest `, Base walks the expected repository set, skips missing or +non-Base-managed repositories, and continues after per-repo failures. Without a +manifest, Base scans discovered local Base-managed projects under the workspace +root. This is the fastest way to roll out shared repo or Project schema repairs +across a local repo family while keeping each repository's `repo configure` +behavior idempotent. + Start a new Base-managed repository with: ```bash diff --git a/cli/bash/commands/basectl/README.md b/cli/bash/commands/basectl/README.md index a00db061..94bf5e93 100644 --- a/cli/bash/commands/basectl/README.md +++ b/cli/bash/commands/basectl/README.md @@ -157,6 +157,11 @@ such command directories exist. Optional utility CLIs such as `caff` and Optional repositories are reported but skipped unless `--include-optional` is supplied, `--dry-run` previews the delegated clone work, and explicit `--manifest ` takes precedence over `workspace.manifest`. +- `basectl workspace configure` applies the existing `basectl repo configure` + repair path across discovered Base-managed projects, or across present + Base-managed repositories from a configured or explicit workspace manifest. + It supports `--dry-run`, skips missing or non-Base-managed repositories, and + continues after per-repo failures. - `basectl version` prints the installed Base version from the repo-root `VERSION` file. - basectl-specific bootstrap subcommands live under `cli/bash/commands/basectl/subcommands/`. - basectl tests live under `cli/bash/commands/basectl/tests/`. diff --git a/cli/bash/commands/basectl/basectl.sh b/cli/bash/commands/basectl/basectl.sh index c920375f..4a47b17a 100644 --- a/cli/bash/commands/basectl/basectl.sh +++ b/cli/bash/commands/basectl/basectl.sh @@ -49,7 +49,7 @@ Commands: Update Base from Git and run setup. projects list [options] List Base-managed projects discovered in the workspace. - workspace [options] + workspace [options] Show workspace status, run checks/diagnostics, clone manifest repos, or sync manifest. version Show the installed Base version. diff --git a/cli/bash/commands/basectl/subcommands/workspace.sh b/cli/bash/commands/basectl/subcommands/workspace.sh index eab4cc46..49d57415 100644 --- a/cli/bash/commands/basectl/subcommands/workspace.sh +++ b/cli/bash/commands/basectl/subcommands/workspace.sh @@ -57,6 +57,23 @@ Fetch and validate a canonical workspace manifest before updating the local mani EOF } +base_workspace_configure_usage() { + cat <<'EOF' +Usage: + basectl workspace configure [options] + +Options: + --workspace Workspace directory to configure. Defaults to workspace.root, then BASE_HOME's parent. + --manifest Local workspace manifest describing expected repositories. + Overrides workspace.manifest from ~/.base.d/config.yaml. + --dry-run Show planned workspace configuration without applying repo changes. + -v Enable DEBUG logging for this subcommand. + -h, --help Show this help text. + +Apply or repair Base-managed GitHub repo configuration across workspace repositories. +EOF +} + base_workspace_subcommand_usage() { case "${1:-}" in status|check|doctor) @@ -68,17 +85,21 @@ base_workspace_subcommand_usage() { pull) base_workspace_pull_usage ;; + configure) + base_workspace_configure_usage + ;; *) cat <<'EOF' Usage: - basectl workspace [options] + basectl workspace [options] Commands: - status Show workspace status. Supports --format text|json. - check Run workspace checks. Supports --format text|json. - doctor Run workspace diagnostics. Supports --format text|json. - clone Clone or validate expected repositories from a workspace manifest. - pull Fetch and validate a canonical workspace manifest source. + status Show workspace status. Supports --format text|json. + check Run workspace checks. Supports --format text|json. + doctor Run workspace diagnostics. Supports --format text|json. + clone Clone or validate expected repositories from a workspace manifest. + pull Fetch and validate a canonical workspace manifest source. + configure Apply repo configure across workspace repositories. Run `basectl workspace --help` for command-specific options. EOF @@ -102,7 +123,7 @@ base_workspace_subcommand_main() { base_workspace_subcommand_usage return 0 ;; - status|check|doctor|clone|pull) + status|check|doctor|clone|pull|configure) shift ;; *) diff --git a/cli/bash/commands/basectl/tests/completions.bats b/cli/bash/commands/basectl/tests/completions.bats index dd073189..78a76f0a 100644 --- a/cli/bash/commands/basectl/tests/completions.bats +++ b/cli/bash/commands/basectl/tests/completions.bats @@ -111,6 +111,10 @@ EOF COMP_CWORD=3; \ _base_basectl_completion; \ printf "workspace_pull_options=%s\n" "${COMPREPLY[*]}"; \ + COMP_WORDS=(basectl workspace configure --); \ + COMP_CWORD=3; \ + _base_basectl_completion; \ + printf "workspace_configure_options=%s\n" "${COMPREPLY[*]}"; \ COMP_WORDS=(basectl onboard --); \ COMP_CWORD=2; \ _base_basectl_completion; \ @@ -215,6 +219,7 @@ EOF [[ "$output" == *"workspace_status_options=--workspace --manifest --format"* ]] [[ "$output" == *"workspace_clone_options=--workspace --manifest --include-optional --dry-run"* ]] [[ "$output" == *"workspace_pull_options=--source --manifest --dry-run"* ]] + [[ "$output" == *"workspace_configure_options=--workspace --manifest --dry-run"* ]] [[ "$output" == *"onboard_options=--profile --dry-run --yes --no-profile"* ]] [[ "$output" == *"onboard_projects=base demo"* ]] [[ "$output" == *"onboard_profiles=dev sre ai dev,sre dev,ai sre,ai dev,sre,ai"* ]] @@ -269,3 +274,22 @@ EOF [[ "$output" == *"--size"* ]] [[ "$output" != *"--type"* ]] } + +@test "Bash completion includes workspace configure options" { + run env \ + BASE_HOME="$BASE_REPO_ROOT" \ + bash -c '\ + source "$BASE_HOME/lib/shell/completions/basectl_completion.sh"; \ + COMP_WORDS=(basectl workspace ""); \ + COMP_CWORD=2; \ + _base_basectl_completion; \ + printf "commands=%s\n" "${COMPREPLY[*]}"; \ + COMP_WORDS=(basectl workspace configure ""); \ + COMP_CWORD=3; \ + _base_basectl_completion; \ + printf "options=%s\n" "${COMPREPLY[*]}"' + + [ "$status" -eq 0 ] + [[ "$output" == *"commands=status check doctor clone pull configure"* ]] + [[ "$output" == *"options=--workspace --manifest --dry-run"* ]] +} diff --git a/cli/bash/commands/basectl/tests/help.bats b/cli/bash/commands/basectl/tests/help.bats index c526ced4..f60cdf8b 100644 --- a/cli/bash/commands/basectl/tests/help.bats +++ b/cli/bash/commands/basectl/tests/help.bats @@ -25,7 +25,7 @@ load ./basectl_helpers.bash [[ "$output" == *"onboard [project] [options]"* ]] [[ "$output" == *"update [options]"* ]] [[ "$output" == *"projects list [options]"* ]] - [[ "$output" == *"workspace [options]"* ]] + [[ "$output" == *"workspace [options]"* ]] [[ "$output" == *"Invoking \`basectl\` with no command starts a Base runtime shell"* ]] [[ "$output" == *"--version"* ]] [[ "$output" == *"Wrapper options:"* ]] @@ -57,7 +57,7 @@ load ./basectl_helpers.bash grep -Fqx ' ci [options]' <<<"$output" grep -Fqx ' release --version [options]' <<<"$output" grep -Fqx ' logs [options]' <<<"$output" - grep -Fqx ' workspace [options]' <<<"$output" + grep -Fqx ' workspace [options]' <<<"$output" [[ "$output" != *"-b DIR"* ]] [[ "$output" != *"Force install"* ]] [[ "$output" != *"-V"* ]] @@ -66,7 +66,7 @@ load ./basectl_helpers.bash @test "AI command context includes current clone and update surfaces" { local commands_file="$BASE_REPO_ROOT/.ai-context/COMMANDS.md" - grep -Fqx -- "- \`basectl workspace \` - inspect workspace" "$commands_file" + grep -Fqx -- "- \`basectl workspace \` - inspect" "$commands_file" grep -Fqx -- " - \`workspace clone\` mutates repository checkouts only when invoked directly;" "$commands_file" grep -Fqx -- "- \`basectl repo \` -" "$commands_file" grep -Fqx -- "- \`basectl update [project]\` - update Base or a named project using the" "$commands_file" diff --git a/cli/bash/commands/basectl/tests/workspace.bats b/cli/bash/commands/basectl/tests/workspace.bats index 5f54a47c..1261f805 100644 --- a/cli/bash/commands/basectl/tests/workspace.bats +++ b/cli/bash/commands/basectl/tests/workspace.bats @@ -141,6 +141,34 @@ EOF [ "$output" = "ARGS=--source $source --manifest $manifest --dry-run" ] } +@test "basectl workspace configure delegates to the Python projects layer" { + local python_bin="$TEST_HOME/.base.d/base/.venv/bin/python" + local workspace="$TEST_TMPDIR/workspace" + local manifest="$TEST_TMPDIR/workspace.yaml" + + mkdir -p "$(dirname "$python_bin")" "$workspace/base" + touch "$manifest" + cat > "$python_bin" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "-m" && "${2:-}" == "base_projects" && "${3:-}" == "configure" ]]; then + printf 'ARGS=%s\n' "${*:4}" + exit 0 +fi +printf 'unexpected workspace configure python args: %s\n' "$*" >&2 +exit 1 +EOF + chmod +x "$python_bin" + workspace="$(cd "$workspace" && pwd -P)" + + run env \ + HOME="$TEST_HOME" \ + PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ + "$BASE_REPO_ROOT/bin/basectl" workspace configure --workspace "$workspace" --manifest "$manifest" --dry-run + + [ "$status" -eq 0 ] + [ "$output" = "ARGS=--workspace $workspace --manifest $manifest --dry-run" ] +} + @test "basectl workspace commands print help without requiring the Base Python venv" { run_basectl workspace status --help @@ -181,6 +209,15 @@ EOF [[ "$output" == *"--manifest "* ]] [[ "$output" == *"--dry-run"* ]] [[ "$output" != *"--format "* ]] + + run_basectl workspace configure --help + + [ "$status" -eq 0 ] + [[ "$output" == *"basectl workspace configure [options]"* ]] + [[ "$output" == *"--workspace "* ]] + [[ "$output" == *"--manifest "* ]] + [[ "$output" == *"--dry-run"* ]] + [[ "$output" != *"--format "* ]] } @test "basectl workspace rejects unknown subcommands" { diff --git a/cli/python/base_projects/engine.py b/cli/python/base_projects/engine.py index fce03fd9..99d69e9d 100644 --- a/cli/python/base_projects/engine.py +++ b/cli/python/base_projects/engine.py @@ -15,13 +15,13 @@ import base_cli from base_cli.config import read_user_config -from base_cli.paths import base_cache_root -from base_cli.paths import discover_manifest +from base_cli.paths import base_cache_root, discover_manifest from base_projects.build_targets import build_targets_project_from_args from base_projects.build_targets import list_build_targets_from_args from base_projects.workspace_manifest import WorkspaceManifest from base_projects.workspace_manifest import WorkspaceManifestRepo from base_projects.workspace_manifest import WorkspaceManifestError +from base_projects.workspace_configure import workspace_configure_from_options from base_projects.workspace_pull import pull_workspace_manifest from base_projects.workspace_reports import ManifestEntry from base_projects.workspace_reports import ProjectDiscoveryError @@ -155,6 +155,9 @@ def dispatch_projects_command( command_arguments, lambda: workspace_pull_command(ctx, options), ), + "configure": lambda: require_no_args_and_run( + "configure", command_arguments, lambda: workspace_configure_from_options(ctx, options) + ), "current": lambda: current_project_from_args(ctx, command_arguments), "manifest": lambda: manifest_project_from_args(ctx, command_arguments), "resolve": lambda: resolve_project_from_args(ctx, command_arguments, options.workspace), @@ -172,8 +175,8 @@ def dispatch_projects_command( ctx.log.error( "Unknown projects command '%s'. Supported commands: list, current, manifest, resolve, " - "status, check, doctor, clone, test-command, demo-script, activation-sources, run-command, run-commands, " - "build-targets, build-target-list, pull.", + "status, check, doctor, clone, configure, test-command, demo-script, activation-sources, run-command, " + "run-commands, build-targets, build-target-list, pull.", command, ) return 2 diff --git a/cli/python/base_projects/tests/test_workspace_configure.py b/cli/python/base_projects/tests/test_workspace_configure.py new file mode 100644 index 00000000..07b3688a --- /dev/null +++ b/cli/python/base_projects/tests/test_workspace_configure.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import io +import os +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest import mock + +from base_projects import engine + + +def write_manifest(project_root: Path, name: str) -> None: + project_root.mkdir(parents=True) + (project_root / "base_manifest.yaml").write_text( + f"project:\n name: {name}\nartifacts: []\n", + encoding="utf-8", + ) + + +def write_workspace_manifest(path: Path, body: str) -> None: + path.write_text(body, encoding="utf-8") + + +def write_git_remote(repo_root: Path, url: str) -> None: + git_dir = repo_root / ".git" + git_dir.mkdir(parents=True) + (git_dir / "config").write_text( + f"[remote \"origin\"]\n\turl = {url}\n", + encoding="utf-8", + ) + + +def write_fake_basectl(base_home: Path, state_file: Path) -> None: + basectl = base_home / "bin" / "basectl" + basectl.parent.mkdir(parents=True) + basectl.write_text( + f"""#!/usr/bin/env bash +printf '%s\\n' "$*" >> {state_file} +repo="" +while (($#)); do + case "$1" in + --repo) + repo="$2" + shift 2 + ;; + *) + shift + ;; + esac +done +if [[ "$repo" == "basefoundry/failing" ]]; then + printf 'simulated configure failure for %s\\n' "$repo" >&2 + exit 1 +fi +printf 'fake configure %s\\n' "$repo" +""", + encoding="utf-8", + ) + basectl.chmod(0o755) + + +def invoke_engine( + args: list[str], + base_home: Path, + home: Path, + user_config: str | None = None, +) -> tuple[int, str, str]: + stdout = io.StringIO() + stderr = io.StringIO() + if user_config is not None: + config_path = home / ".base.d" / "config.yaml" + config_path.parent.mkdir(parents=True) + config_path.write_text(user_config, encoding="utf-8") + env = { + "HOME": str(home), + "BASE_HOME": str(base_home), + "BASE_PROJECT": "", + "BASE_PROJECT_MANIFEST": "", + } + with mock.patch.dict(os.environ, env): + with redirect_stdout(stdout), redirect_stderr(stderr): + status = engine.main(args) + return status, stdout.getvalue(), stderr.getvalue() + + +class WorkspaceConfigureTests(unittest.TestCase): + def test_workspace_configure_dry_run_scans_base_managed_repositories(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + workspace = root / "workspace" + base_home = root / "base" + state_file = root / "basectl-calls" + home.mkdir() + base_home.mkdir() + write_fake_basectl(base_home, state_file) + write_manifest(workspace / "base", "base") + write_git_remote(workspace / "base", "git@github.com:basefoundry/base.git") + write_manifest(workspace / "local-only", "local-only") + write_git_remote(workspace / "local-only", "file:///repos/local-only.git") + (workspace / "notes").mkdir(parents=True) + + status, stdout, stderr = invoke_engine( + ["configure", "--workspace", str(workspace), "--dry-run"], + base_home, + home, + ) + + self.assertEqual(status, 0) + self.assertEqual(stderr, "") + self.assertIn(f"Workspace configure: {workspace.resolve()} (2 discovered project(s))", stdout) + self.assertIn( + f"CONFIGURE repository 'base' at '{(workspace / 'base').resolve()}' for 'basefoundry/base'.", + stdout, + ) + self.assertIn("SKIP repository 'local-only' has no supported GitHub origin remote.", stdout) + self.assertIn("[DRY-RUN] No repositories were modified.", stdout) + self.assertIn("Workspace configure completed: configured=1 skipped=1 failed=0.", stdout) + self.assertEqual( + state_file.read_text(encoding="utf-8").splitlines(), + [ + f"repo configure {(workspace / 'base').resolve()} --repo basefoundry/base --dry-run", + ], + ) + + def test_workspace_configure_manifest_continues_after_failures_and_skips_missing_repos(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + workspace = root / "workspace" + base_home = root / "base" + state_file = root / "basectl-calls" + manifest_path = root / "workspace.yaml" + home.mkdir() + base_home.mkdir() + workspace.mkdir() + write_fake_basectl(base_home, state_file) + write_manifest(workspace / "base", "base") + write_manifest(workspace / "failing", "failing") + write_workspace_manifest( + manifest_path, + """ +schema_version: 1 +workspace: + name: demo-suite +repos: + - name: base + url: git@github.com:basefoundry/base.git + - name: missing + url: git@github.com:basefoundry/missing.git + - name: failing + url: git@github.com:basefoundry/failing.git +""", + ) + + status, stdout, stderr = invoke_engine( + [ + "configure", + "--workspace", + str(workspace), + "--manifest", + str(manifest_path), + ], + base_home, + home, + ) + + self.assertEqual(status, 1) + self.assertIn("simulated configure failure for basefoundry/failing", stderr) + self.assertIn("Configure failed for repository 'failing'.", stderr) + self.assertIn(f"Workspace configure: {workspace.resolve()} (3 manifest repos)", stdout) + self.assertIn(f"Workspace manifest: {manifest_path.resolve()} (demo-suite)", stdout) + self.assertIn( + f"CONFIGURE repository 'base' at '{(workspace / 'base').resolve()}' for 'basefoundry/base'.", + stdout, + ) + self.assertIn( + f"SKIP repository 'missing' is missing at '{(workspace / 'missing').resolve()}'.", + stdout, + ) + self.assertIn("Workspace configure completed: configured=1 skipped=1 failed=1.", stdout) + self.assertEqual( + state_file.read_text(encoding="utf-8").splitlines(), + [ + f"repo configure {(workspace / 'base').resolve()} --repo basefoundry/base", + f"repo configure {(workspace / 'failing').resolve()} --repo basefoundry/failing", + ], + ) + + def test_workspace_configure_uses_configured_manifest_when_flag_is_omitted(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + workspace = root / "workspace" + base_home = root / "base" + state_file = root / "basectl-calls" + manifest_path = root / "workspace.yaml" + home.mkdir() + base_home.mkdir() + workspace.mkdir() + write_fake_basectl(base_home, state_file) + write_manifest(workspace / "base", "base") + write_workspace_manifest( + manifest_path, + """ +schema_version: 1 +workspace: + name: demo-suite +repos: + - name: base + url: https://github.com/basefoundry/base.git +""", + ) + + status, stdout, stderr = invoke_engine( + ["configure", "--workspace", str(workspace), "--dry-run"], + base_home, + home, + user_config=f"workspace:\n manifest: {manifest_path}\n", + ) + + self.assertEqual(status, 0) + self.assertEqual(stderr, "") + self.assertIn(f"Workspace manifest: {manifest_path.resolve()} (demo-suite)", stdout) + self.assertEqual( + state_file.read_text(encoding="utf-8").splitlines(), + [ + f"repo configure {(workspace / 'base').resolve()} --repo basefoundry/base --dry-run", + ], + ) diff --git a/cli/python/base_projects/workspace_configure.py b/cli/python/base_projects/workspace_configure.py new file mode 100644 index 00000000..3e61c766 --- /dev/null +++ b/cli/python/base_projects/workspace_configure.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import configparser +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import base_cli +from base_projects.workspace_manifest import WorkspaceManifest +from base_projects.workspace_manifest import WorkspaceManifestError +from base_projects.workspace_manifest import WorkspaceManifestRepo +from base_projects.workspace_reports import ProjectDiscoveryError +from base_projects.workspace_reports import resolve_workspace_manifest +from base_projects.workspace_reports import workspace_manifest_entries + + +@dataclass(frozen=True) +class WorkspaceConfigureTarget: + name: str + root: Path + repo_spec: str | None + skip_reason: str | None = None + + +@dataclass(frozen=True) +class WorkspaceConfigureCounts: + configured: int = 0 + skipped: int = 0 + failed: int = 0 + + +def workspace_configure_from_options( + ctx: base_cli.Context, + options: Any, +) -> int: + if options.output_format != "text": + ctx.log.error("Unsupported output format '%s'. Expected: text.", options.output_format) + return 2 + + try: + workspace_root = resolve_workspace_root(ctx, options.workspace) + manifest = resolve_workspace_manifest(effective_workspace_manifest(ctx, options.workspace_manifest)) + except (ProjectDiscoveryError, WorkspaceManifestError) as exc: + ctx.log.error(str(exc)) + return 1 + + return workspace_configure_command(ctx, workspace_root, manifest, dry_run=options.dry_run) + + +def workspace_configure_command( + ctx: base_cli.Context, + workspace_root: Path, + workspace_manifest: WorkspaceManifest | None, + *, + dry_run: bool, +) -> int: + if ctx.base_home is None: + ctx.log.error("BASE_HOME is required to configure workspace repositories.") + return 1 + + basectl = ctx.base_home / "bin" / "basectl" + targets = workspace_configure_targets(workspace_root, workspace_manifest) + print_workspace_configure_header(workspace_root, workspace_manifest, len(targets)) + + counts = WorkspaceConfigureCounts() + for target in targets: + counts = configure_workspace_target(ctx, basectl, target, counts, dry_run=dry_run) + + if dry_run: + print("[DRY-RUN] No repositories were modified.") + + print( + "Workspace configure completed: " + f"configured={counts.configured} skipped={counts.skipped} failed={counts.failed}." + ) + return 1 if counts.failed else 0 + + +def workspace_configure_targets( + workspace_root: Path, + workspace_manifest: WorkspaceManifest | None, +) -> tuple[WorkspaceConfigureTarget, ...]: + if workspace_manifest is not None: + return tuple(workspace_configure_manifest_target(workspace_root, repo) for repo in workspace_manifest.repos) + + targets: list[WorkspaceConfigureTarget] = [] + for entry in workspace_manifest_entries(workspace_root): + root = entry.path.parent.resolve() + targets.append( + WorkspaceConfigureTarget( + name=root.name, + root=root, + repo_spec=github_origin_repo_spec(root), + ) + ) + return tuple(targets) + + +def workspace_configure_manifest_target( + workspace_root: Path, + repo: WorkspaceManifestRepo, +) -> WorkspaceConfigureTarget: + root = (workspace_root / repo.name).resolve() + if not root.exists(): + return WorkspaceConfigureTarget( + name=repo.name, + root=root, + repo_spec=None, + skip_reason=f"repository '{repo.name}' is missing at '{root}'", + ) + if not (root / "base_manifest.yaml").is_file(): + return WorkspaceConfigureTarget( + name=repo.name, + root=root, + repo_spec=None, + skip_reason=f"repository '{repo.name}' does not contain base_manifest.yaml", + ) + + return WorkspaceConfigureTarget( + name=repo.name, + root=root, + repo_spec=workspace_manifest_repo_spec(repo) or github_origin_repo_spec(root), + ) + + +def configure_workspace_target( + ctx: base_cli.Context, + basectl: Path, + target: WorkspaceConfigureTarget, + counts: WorkspaceConfigureCounts, + *, + dry_run: bool, +) -> WorkspaceConfigureCounts: + if target.skip_reason is not None: + print(f"SKIP {target.skip_reason}.") + return WorkspaceConfigureCounts(counts.configured, counts.skipped + 1, counts.failed) + if target.repo_spec is None: + print(f"SKIP repository '{target.name}' has no supported GitHub origin remote.") + return WorkspaceConfigureCounts(counts.configured, counts.skipped + 1, counts.failed) + + print(f"CONFIGURE repository '{target.name}' at '{target.root}' for '{target.repo_spec}'.") + status = configure_workspace_repo(ctx, basectl, target, dry_run=dry_run) + if status == 0: + return WorkspaceConfigureCounts(counts.configured + 1, counts.skipped, counts.failed) + return WorkspaceConfigureCounts(counts.configured, counts.skipped, counts.failed + 1) + + +def configure_workspace_repo( + ctx: base_cli.Context, + basectl: Path, + target: WorkspaceConfigureTarget, + *, + dry_run: bool, +) -> int: + command = [str(basectl), "repo", "configure", str(target.root), "--repo", str(target.repo_spec)] + if dry_run: + command.append("--dry-run") + + try: + result = subprocess.run(command, check=False, capture_output=True, text=True) + except OSError as exc: + ctx.log.error("Could not run basectl repo configure for repository '%s': %s", target.name, exc) + return 1 + + if result.stdout: + print(result.stdout, end="") + if result.stderr: + print(result.stderr, end="", file=sys.stderr) + if result.returncode == 0: + return 0 + + ctx.log.error("Configure failed for repository '%s'.", target.name) + return 1 + + +def print_workspace_configure_header( + workspace_root: Path, + workspace_manifest: WorkspaceManifest | None, + target_count: int, +) -> None: + if workspace_manifest is None: + print(f"Workspace configure: {workspace_root} ({target_count} discovered project(s))") + return + + print(f"Workspace configure: {workspace_root} ({target_count} manifest repos)") + print(f"Workspace manifest: {workspace_manifest.path} ({workspace_manifest.name})") + + +def resolve_workspace_root(ctx: base_cli.Context, workspace: str | None) -> Path: + if workspace: + return Path(workspace).expanduser().resolve() + if ctx.user_config.workspace.root is not None: + return ctx.user_config.workspace.root + if ctx.base_home is None: + raise ProjectDiscoveryError("BASE_HOME is required to discover workspace projects.") + return ctx.base_home.parent.resolve() + + +def effective_workspace_manifest(ctx: base_cli.Context, workspace_manifest: str | None) -> str | None: + if workspace_manifest is not None: + return workspace_manifest + configured_manifest = ctx.user_config.workspace.manifest + if configured_manifest is None: + return None + return str(configured_manifest) + + +def workspace_manifest_repo_spec(repo: WorkspaceManifestRepo) -> str | None: + if repo.url is None: + return None + return github_repo_spec(repo.url) + + +def github_origin_repo_spec(root: Path) -> str | None: + try: + result = subprocess.run( + ["git", "-C", str(root), "config", "--get", "remote.origin.url"], + check=False, + capture_output=True, + text=True, + ) + except OSError: + origin_url = git_config_origin_url(root) + return github_repo_spec(origin_url) if origin_url else None + if result.returncode != 0: + origin_url = git_config_origin_url(root) + return github_repo_spec(origin_url) if origin_url else None + return github_repo_spec(result.stdout.strip()) + + +def git_config_origin_url(root: Path) -> str | None: + config_path = root / ".git" / "config" + if not config_path.is_file(): + return None + parser = configparser.ConfigParser() + try: + parser.read(config_path, encoding="utf-8") + except configparser.Error: + return None + section = 'remote "origin"' + if not parser.has_option(section, "url"): + return None + return parser.get(section, "url").strip() + + +def github_repo_spec(url: str) -> str | None: + parsed = urlparse(url) + if parsed.scheme and parsed.hostname == "github.com": + return github_repo_spec_from_path(parsed.path) + + git_ssh_prefix = "git@github.com:" + if url.startswith(git_ssh_prefix): + return github_repo_spec_from_path(url[len(git_ssh_prefix) :]) + + return None + + +def github_repo_spec_from_path(path: str) -> str | None: + normalized = path.strip("/") + if normalized.endswith(".git"): + normalized = normalized[:-4] + parts = normalized.split("/") + if len(parts) != 2 or not all(parts): + return None + return f"{parts[0]}/{parts[1]}" diff --git a/docs/command-reference.md b/docs/command-reference.md index 3af527bf..f02a55f7 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -58,6 +58,7 @@ inspect the resolved command contract first. | `basectl workspace doctor` | Run read-only diagnostics across workspace projects. Uses `workspace.manifest` from user config unless `--manifest` is supplied. | `--workspace `, `--manifest `, `--format ` | | `basectl workspace clone` | Clone or validate expected repositories from a workspace manifest. Uses `workspace.manifest` from user config unless `--manifest` is supplied. | `--workspace `, `--manifest `, `--include-optional`, `--dry-run` | | `basectl workspace pull` | Explicitly fetch and validate a canonical workspace manifest source before updating the local workspace manifest. Uses `workspace.manifest_source` and `workspace.manifest` from user config unless flags are supplied. | `--source `, `--manifest `, `--dry-run` | +| `basectl workspace configure` | Apply the existing `repo configure` repair path across discovered Base-managed workspace repositories or an explicit workspace manifest. Skips missing, non-Base-managed, or non-GitHub repos and continues after per-repo failures. | `--workspace `, `--manifest `, `--dry-run` | ## Repository And GitHub Workflow diff --git a/docs/workspace-manifest.md b/docs/workspace-manifest.md index 295e4d5e..a86bea48 100644 --- a/docs/workspace-manifest.md +++ b/docs/workspace-manifest.md @@ -264,6 +264,24 @@ repositories. `--dry-run` forwards to each delegated `basectl repo clone` operation so the resolved repository specs, destinations, and conflicts can be reviewed before the filesystem changes. +The configure path applies the existing single-repo repair behavior across the +workspace: + +```bash +basectl workspace configure --dry-run +basectl workspace configure +basectl workspace configure --manifest ~/work/workspace.yaml --dry-run +``` + +Without a manifest, Base scans discovered local Base-managed projects under the +workspace root and delegates each supported GitHub checkout to +`basectl repo configure --repo `. With a manifest, Base walks +the expected repository set, skips missing or non-Base-managed repositories, and +uses the manifest URL when it identifies a GitHub repository. The command +continues after per-repo failures and reports configured, skipped, and failed +counts. Use this after shared repo or Project schema changes when each local +repo should receive the same idempotent `repo configure` repair path. + ## Relationship To Onboarding `basectl onboard` currently guides first-run Base setup. It should not become a @@ -305,6 +323,11 @@ repositories by default, skips missing optional repositories unless `--include-optional` is supplied, and exits nonzero when any delegated clone or checkout validation fails. +`basectl workspace configure --manifest ` configures present Base-managed +expected repositories through `basectl repo configure`. It skips missing +repositories and present repositories without `base_manifest.yaml`, and exits +nonzero only when a delegated configure command fails. + The v1 implementation is intentionally still conservative. Clone is explicit; -update, pull, reset, project setup, and authentication management remain outside -the workspace manifest contract. +configure is explicit; update, pull, reset, project setup, and authentication +management remain outside the workspace manifest contract. diff --git a/lib/shell/completions/basectl_completion.sh b/lib/shell/completions/basectl_completion.sh index 7f327a48..acc6a473 100644 --- a/lib/shell/completions/basectl_completion.sh +++ b/lib/shell/completions/basectl_completion.sh @@ -91,7 +91,7 @@ _base_basectl_completion() { ;; workspace) if ((COMP_CWORD == 2)); then - _base_basectl_completion_compgen "status check doctor clone pull" "$cur" + _base_basectl_completion_compgen "status check doctor clone pull configure" "$cur" else case "${COMP_WORDS[2]:-}" in status|check|doctor) @@ -103,6 +103,9 @@ _base_basectl_completion() { pull) _base_basectl_completion_compgen "--source --manifest --dry-run -v -h --help" "$cur" ;; + configure) + _base_basectl_completion_compgen "--workspace --manifest --dry-run -v -h --help" "$cur" + ;; esac fi ;;