From 6003da3261bf910a321127ece48c276d756317f5 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:46:35 +0530 Subject: [PATCH 1/3] fix(workspace): distinguish blocked roots from pull failures --- .ai-context/COMMANDS.md | 9 +- .../tests/test_workspace_update.py | 109 +++++++++++++++--- cli/python/base_projects/workspace_update.py | 92 ++++++++++++--- docs/command-reference.md | 9 +- docs/schemas/workspace-update.json | 5 + docs/workspace-manifest.md | 21 ++-- 6 files changed, 202 insertions(+), 43 deletions(-) diff --git a/.ai-context/COMMANDS.md b/.ai-context/COMMANDS.md index 5dec535d..42cf7b00 100644 --- a/.ai-context/COMMANDS.md +++ b/.ai-context/COMMANDS.md @@ -125,8 +125,13 @@ the v1.x compatibility window; new automation should use `--debug-wrapper`. skipped. - `workspace update --dry-run` previews the ordered Git pull plan; without `--dry-run`, it continues after per-repo failures and reports - updated/unchanged/skipped/failed counts. JSON dry runs perform the same - read-only Git preflight as apply runs and report `planned` results without + updated/unchanged/skipped/blocked/failed counts. Required roots that fail + read-only preflight are shown as blocked and keep a nonzero exit status; + actual Git pull errors are counted as failed. Git-registered linked + worktrees nested under a primary root are excluded from that root's + untracked-file check, while arbitrary untracked files and real primary-root + edits still block updates. JSON dry runs perform the same read-only Git + preflight as apply runs and report planned or blocked results without pulling or mutating a checkout. - `basectl repo ` - create repository baselines, clone GitHub repositories into the configured diff --git a/cli/python/base_projects/tests/test_workspace_update.py b/cli/python/base_projects/tests/test_workspace_update.py index 8f3f46f6..31142e31 100644 --- a/cli/python/base_projects/tests/test_workspace_update.py +++ b/cli/python/base_projects/tests/test_workspace_update.py @@ -355,12 +355,15 @@ def test_empty_workspace_update_text_and_json_are_successful_no_ops(self) -> Non ) self.assertEqual(text_status, 0) self.assertEqual(text_stderr, "") - self.assertIn("Workspace update plan complete: planned=0 skipped=0 failed=0.", text_stdout) + self.assertIn("Workspace update plan complete: planned=0 skipped=0 blocked=0 failed=0.", text_stdout) self.assertEqual(json_status, 0) self.assertEqual(json_stderr, "") payload = json.loads(json_stdout) self.assertEqual(payload["repositories"], []) - self.assertEqual(payload["counts"], {"planned": 0, "updated": 0, "unchanged": 0, "skipped": 0, "failed": 0}) + self.assertEqual( + payload["counts"], + {"planned": 0, "updated": 0, "unchanged": 0, "skipped": 0, "blocked": 0, "failed": 0}, + ) def test_workspace_update_debug_log_formats_captured_output(self) -> None: cases = ( @@ -469,9 +472,9 @@ def test_workspace_update_rejects_repository_target_outside_workspace(self) -> N self.assertEqual(status, 1) self.assertEqual(stderr, "") - self.assertIn("SKIP skipped", stdout) + self.assertIn("SKIP blocked", stdout) self.assertIn("resolves outside workspace root", stdout) - self.assertIn("Workspace update completed: updated=0 unchanged=0 skipped=1 failed=1.", stdout) + self.assertIn("Workspace update completed: updated=0 unchanged=0 skipped=0 blocked=1 failed=0.", stdout) run.assert_not_called() def test_workspace_update_allows_repository_symlink_inside_workspace(self) -> None: @@ -527,7 +530,7 @@ def test_workspace_update_dry_run_preserves_order_and_includes_active_base(self) assert_workspace_result(self, stdout, "optional-missing", "SKIP", "skipped") assert_workspace_result(self, stdout, "later", "PULL", "planned") self.assertNotIn("active Base control plane is managed from BASE_HOME", stdout) - self.assertIn("Workspace update plan complete: planned=5 skipped=2 failed=1.", stdout) + self.assertIn("Workspace update plan complete: planned=5 skipped=1 blocked=1 failed=0.", stdout) self.assertIn("[DRY-RUN] No repositories were modified.", stdout) self.assertLess(stdout.index("\nbase "), stdout.index("\nfirst ")) self.assertLess(stdout.index("\nfirst "), stdout.index("\nlater ")) @@ -634,6 +637,18 @@ def test_workspace_update_preflight_detects_actual_linked_worktree(self) -> None (primary / "README.md").write_text("workspace test\n", encoding="utf-8") subprocess.run(["git", "-C", str(primary), "add", "README.md"], check=True) subprocess.run(["git", "-C", str(primary), "commit", "-m", "initial"], check=True, capture_output=True) + remote = root / "origin.git" + subprocess.run( + ["git", "init", "--bare", "--initial-branch=main", str(remote)], + check=True, + capture_output=True, + ) + subprocess.run(["git", "-C", str(primary), "remote", "add", "origin", str(remote)], check=True) + subprocess.run( + ["git", "-C", str(primary), "push", "--set-upstream", "origin", "main"], + check=True, + capture_output=True, + ) subprocess.run( ["git", "-C", str(primary), "worktree", "add", "-b", "pr-33-review", str(linked), "HEAD"], check=True, @@ -653,6 +668,63 @@ def test_workspace_update_preflight_detects_actual_linked_worktree(self) -> None self.assertEqual(result.preflight, ("linked_worktree",)) self.assertIn("linked Git worktree", result.detail or "") + def test_workspace_update_ignores_registered_nested_worktree_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + primary = root / "primary" + nested = primary / "base-cli-worktrees" / "242-api-freeze" + primary.mkdir() + subprocess.run(["git", "init", "--initial-branch=main", str(primary)], check=True, capture_output=True) + subprocess.run(["git", "-C", str(primary), "config", "user.name", "Workspace Test"], check=True) + subprocess.run( + ["git", "-C", str(primary), "config", "user.email", "workspace-test@example.com"], + check=True, + ) + (primary / "README.md").write_text("workspace test\n", encoding="utf-8") + subprocess.run(["git", "-C", str(primary), "add", "README.md"], check=True) + subprocess.run(["git", "-C", str(primary), "commit", "-m", "initial"], check=True, capture_output=True) + remote = root / "origin.git" + subprocess.run( + ["git", "init", "--bare", "--initial-branch=main", str(remote)], + check=True, + capture_output=True, + ) + subprocess.run(["git", "-C", str(primary), "remote", "add", "origin", str(remote)], check=True) + subprocess.run( + ["git", "-C", str(primary), "push", "--set-upstream", "origin", "main"], + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "-C", str(primary), "worktree", "add", "-b", "pr-242-api-freeze", str(nested), "HEAD"], + check=True, + capture_output=True, + ) + + result = workspace_update.preflight_workspace_update_target( + workspace_update.WorkspaceUpdateTarget( + name="demo", + root=primary, + action="pull", + default_branch="main", + ) + ) + + (primary / "primary-change.txt").write_text("real primary edit\n", encoding="utf-8") + dirty_result = workspace_update.preflight_workspace_update_target( + workspace_update.WorkspaceUpdateTarget( + name="demo", + root=primary, + action="pull", + default_branch="main", + ) + ) + + self.assertIsNone(result) + self.assertIsNotNone(dirty_result) + assert dirty_result is not None + self.assertEqual(dirty_result.preflight, ("dirty",)) + def test_workspace_update_reports_preflight_diagnostics_in_text(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) @@ -691,9 +763,9 @@ def test_workspace_update_reports_preflight_diagnostics_in_text(self) -> None: self.assertEqual(status, 1) self.assertEqual(stderr, "") - assert_workspace_result(self, stdout, "demo", "SKIP", "skipped") + assert_workspace_result(self, stdout, "demo", "SKIP", "blocked") self.assertIn("working tree is dirty", stdout) - self.assertIn("Workspace update completed: updated=0 unchanged=0 skipped=1 failed=1.", stdout) + self.assertIn("Workspace update completed: updated=0 unchanged=0 skipped=0 blocked=1 failed=0.", stdout) run.assert_not_called() def test_workspace_update_json_reports_preflight_classification(self) -> None: @@ -740,7 +812,10 @@ def test_workspace_update_json_reports_preflight_classification(self) -> None: self.assertEqual(payload["repositories"][0]["status"], "skipped") self.assertTrue(payload["repositories"][0]["fatal"]) self.assertEqual(payload["repositories"][0]["preflight"], ["linked_worktree"]) - self.assertEqual(payload["counts"], {"planned": 0, "updated": 0, "unchanged": 0, "skipped": 1, "failed": 1}) + self.assertEqual( + payload["counts"], + {"planned": 0, "updated": 0, "unchanged": 0, "skipped": 0, "blocked": 1, "failed": 0}, + ) def test_workspace_update_pulls_serially_and_aggregates_failures(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -824,7 +899,7 @@ def test_workspace_update_pulls_serially_and_aggregates_failures(self) -> None: self.assertIn("fatal: Not possible to fast-forward, aborting.", stdout) assert_workspace_result(self, stdout, "later", "PULL", "updated") self.assertNotIn("Already up to date.", stdout) - self.assertIn("Workspace update completed: updated=2 unchanged=2 skipped=1 failed=1.", stdout) + self.assertIn("Workspace update completed: updated=2 unchanged=2 skipped=1 blocked=0 failed=1.", stdout) self.assertEqual( [call.kwargs["cwd"] for call in run.call_args_list], [ @@ -960,7 +1035,10 @@ def test_workspace_update_json_dry_run_is_machine_readable_and_does_not_run_git( self.assertTrue(payload["dry_run"]) self.assertEqual(payload["selected_repositories"], ["first", "later"]) self.assertEqual([repo["status"] for repo in payload["repositories"]], ["planned", "planned"]) - self.assertEqual(payload["counts"], {"planned": 2, "updated": 0, "unchanged": 0, "skipped": 0, "failed": 0}) + self.assertEqual( + payload["counts"], + {"planned": 2, "updated": 0, "unchanged": 0, "skipped": 0, "blocked": 0, "failed": 0}, + ) run.assert_not_called() def test_workspace_update_json_reports_results_and_exit_code(self) -> None: @@ -1021,7 +1099,10 @@ def test_workspace_update_json_reports_results_and_exit_code(self) -> None: self.assertEqual(payload["repositories"][1]["exit_code"], 1) self.assertIn("Not possible to fast-forward", payload["repositories"][1]["detail"]) self.assertEqual(payload["repositories"][2]["status"], "skipped") - self.assertEqual(payload["counts"], {"planned": 0, "updated": 1, "unchanged": 0, "skipped": 1, "failed": 1}) + self.assertEqual( + payload["counts"], + {"planned": 0, "updated": 1, "unchanged": 0, "skipped": 1, "blocked": 0, "failed": 1}, + ) def test_workspace_update_optional_preflight_skip_is_nonfatal(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -1061,7 +1142,7 @@ def test_workspace_update_optional_preflight_skip_is_nonfatal(self) -> None: self.assertEqual(status, 0) self.assertEqual(stderr, "") - self.assertIn("Workspace update completed: updated=0 unchanged=0 skipped=1 failed=0.", stdout) + self.assertIn("Workspace update completed: updated=0 unchanged=0 skipped=1 blocked=0 failed=0.", stdout) def test_workspace_update_dry_run_reports_preflight_diagnostics(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -1102,9 +1183,9 @@ def test_workspace_update_dry_run_reports_preflight_diagnostics(self) -> None: self.assertEqual(status, 1) self.assertEqual(stderr, "") - assert_workspace_result(self, stdout, "demo", "SKIP", "skipped") + assert_workspace_result(self, stdout, "demo", "SKIP", "blocked") self.assertIn("working tree is dirty", stdout) - self.assertIn("Workspace update plan complete: planned=0 skipped=1 failed=1.", stdout) + self.assertIn("Workspace update plan complete: planned=0 skipped=0 blocked=1 failed=0.", stdout) run.assert_not_called() def test_workspace_update_requires_a_manifest(self) -> None: diff --git a/cli/python/base_projects/workspace_update.py b/cli/python/base_projects/workspace_update.py index 1e1a05b5..bd2371c0 100644 --- a/cli/python/base_projects/workspace_update.py +++ b/cli/python/base_projects/workspace_update.py @@ -22,6 +22,7 @@ WorkspaceUpdateAction = Literal["pull", "skip"] WorkspaceUpdateStatus = Literal["planned", "updated", "unchanged", "skipped", "failed"] +WorkspaceUpdateGitStatus = tuple[tuple[bool, str | None, str | None], str] WorkspaceUpdatePreflightIssue = Literal[ "dirty", "linked_worktree", @@ -55,6 +56,7 @@ class WorkspaceUpdateCounts: updated: int = 0 unchanged: int = 0 skipped: int = 0 + blocked: int = 0 failed: int = 0 @@ -172,17 +174,18 @@ def workspace_update_command( if dry_run: print( "Workspace update plan complete: " - f"planned={counts.planned} skipped={counts.skipped} failed={counts.failed}." + f"planned={counts.planned} skipped={counts.skipped} " + f"blocked={counts.blocked} failed={counts.failed}." ) print("[DRY-RUN] No repositories were modified.") - return base_cli.ExitCode.FAILURE if counts.failed else base_cli.ExitCode.SUCCESS + return base_cli.ExitCode.FAILURE if counts.failed or counts.blocked else base_cli.ExitCode.SUCCESS print( "Workspace update completed: " f"updated={counts.updated} unchanged={counts.unchanged} " - f"skipped={counts.skipped} failed={counts.failed}." + f"skipped={counts.skipped} blocked={counts.blocked} failed={counts.failed}." ) - return base_cli.ExitCode.FAILURE if counts.failed else base_cli.ExitCode.SUCCESS + return base_cli.ExitCode.FAILURE if counts.failed or counts.blocked else base_cli.ExitCode.SUCCESS def select_workspace_update_repositories( @@ -241,11 +244,12 @@ def render_workspace_update_json( "updated": counts.updated, "unchanged": counts.unchanged, "skipped": counts.skipped, + "blocked": counts.blocked, "failed": counts.failed, }, } base_cli.render_document(payload, requested_format="json") - return base_cli.ExitCode.FAILURE if counts.failed else base_cli.ExitCode.SUCCESS + return base_cli.ExitCode.FAILURE if counts.failed or counts.blocked else base_cli.ExitCode.SUCCESS def workspace_update_repository_to_json( @@ -339,6 +343,8 @@ def print_workspace_update_result( ) -> None: action = target.action.upper() outcome = result.status + if result.status == "skipped" and target.fatal: + outcome = "blocked" if result.exit_code is not None: outcome = f"{outcome} (exit {result.exit_code})" print(f"{target.name:<{name_width}} {action:<6} {outcome}") @@ -357,8 +363,9 @@ def update_workspace_update_counts( planned=counts.planned + (result.status == "planned"), updated=counts.updated + (result.status == "updated"), unchanged=counts.unchanged + (result.status == "unchanged"), - skipped=counts.skipped + (result.status == "skipped"), - failed=counts.failed + (result.status == "failed" or fatal), + skipped=counts.skipped + (result.status == "skipped" and not fatal), + blocked=counts.blocked + (result.status == "skipped" and fatal), + failed=counts.failed + (result.status == "failed"), ) @@ -489,14 +496,40 @@ def workspace_update_git_directories( def workspace_update_git_status( target: WorkspaceUpdateTarget, ) -> tuple[bool, str | None, str | None] | WorkspaceUpdateResult: + target_root = target.root.resolve() + status_arguments = [ + "status", + "--porcelain=v1", + "--branch", + "--untracked-files=all", + ] + status_result = workspace_update_git_status_for_arguments(target, status_arguments) + if isinstance(status_result, WorkspaceUpdateResult): + return status_result + checkout_status, status_output = status_result + if not checkout_status[0] or not any(line.startswith("??") for line in status_output.splitlines()): + return checkout_status + + nested_worktrees = workspace_update_nested_worktree_paths(target) + if not nested_worktrees: + return checkout_status + + filtered_status_arguments = [*status_arguments, "--", "."] + filtered_status_arguments.extend( + f":(exclude){path.relative_to(target_root).as_posix()}" for path in nested_worktrees + ) + filtered_status = workspace_update_git_status_for_arguments(target, filtered_status_arguments) + if isinstance(filtered_status, WorkspaceUpdateResult): + return checkout_status + return filtered_status[0] + + +def workspace_update_git_status_for_arguments( + target: WorkspaceUpdateTarget, + arguments: list[str], +) -> WorkspaceUpdateGitStatus | WorkspaceUpdateResult: try: - status = run_workspace_git_probe( - target.root, - "status", - "--porcelain=v1", - "--branch", - "--untracked-files=all", - ) + status = run_workspace_git_probe(target.root, *arguments) except subprocess.TimeoutExpired: return workspace_update_preflight_result( ("preflight_failed",), @@ -514,8 +547,37 @@ def workspace_update_git_status( ("preflight_failed",), f"could not inspect Git checkout '{target.root}': {detail}", ) + return parse_workspace_git_status(status.stdout), status.stdout + - return parse_workspace_git_status(status.stdout) +def workspace_update_nested_worktree_paths(target: WorkspaceUpdateTarget) -> tuple[Path, ...]: + """Return registered linked worktrees nested inside a primary checkout.""" + target_root = target.root.resolve() + try: + worktrees = run_workspace_git_probe(target.root, "worktree", "list", "--porcelain") + except (OSError, subprocess.TimeoutExpired): + return () + if worktrees.returncode != 0: + return () + + nested: list[Path] = [] + for path in parse_workspace_worktree_paths(worktrees.stdout): + if path == target_root: + continue + try: + path.relative_to(target_root) + except ValueError: + continue + nested.append(path) + return tuple(nested) + + +def parse_workspace_worktree_paths(output: str) -> tuple[Path, ...]: + return tuple( + Path(line.removeprefix("worktree ")).resolve() + for line in output.splitlines() + if line.startswith("worktree ") and line.removeprefix("worktree ").strip() + ) def workspace_update_preflight_state( diff --git a/docs/command-reference.md b/docs/command-reference.md index e10b9729..0cc7095f 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -211,7 +211,7 @@ manifest trust. | `basectl workspace agent-brief` | Report local baseline, agent-guidance, AI-context, environment, validation evidence, and derived next actions for expected and extra Base-managed repositories without mutation or network calls. Requires a configured or explicit workspace manifest. JSON schema: [`docs/schemas/workspace-agent-brief.json`](schemas/workspace-agent-brief.json). | `--workspace `, `--manifest `, `--format ` | | `basectl workspace clone` | Clone or validate expected repositories from a workspace manifest. Missing-repository materialization is GitHub-only today because this path delegates to `repo clone`. Uses `workspace.manifest` from user config unless `--manifest` is supplied. Text output uses a stable repository/action/result table with aggregate counts. | `--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 update` | Run `git pull --ff-only` across present repositories in manifest order, including the active Base checkout when it is the manifest's `base` target. `--repos` selects a validated comma-separated subset. Continues after failures, skips missing optional repositories, treats missing required repositories as failures, and reports updated/unchanged/skipped/failed counts. `--format json` emits the stable schema-versioned report. | `--workspace `, `--manifest `, `--repos `, `--dry-run`, `--format ` | +| `basectl workspace update` | Run `git pull --ff-only` across present repositories in manifest order, including the active Base checkout when it is the manifest's `base` target. `--repos` selects a validated comma-separated subset. Continues after failures, skips missing optional repositories, reports required missing or unsafe roots as blocked, and reports updated/unchanged/skipped/blocked/failed counts. Git-registered linked worktrees nested under a primary root are excluded from that root's untracked-file check; real primary-root edits still block updates. `--format json` emits the stable schema-versioned report. | `--workspace `, `--manifest `, `--repos `, `--dry-run`, `--format ` | | `basectl workspace init ` | Initialize a workspace from a workspace configuration repository, update local workspace config, and optionally materialize member repositories. | `--owner `, `--path `, `--workspace `, `--manifest `, `--include-optional`, `--dry-run` | | `basectl workspace configure` | Preview the existing `repo configure` repair path by default across discovered Base-managed workspace repositories or an explicit workspace manifest. Use `--apply` to authorize changes; interactive runs prompt unless `--yes` is supplied. Skips missing, non-Base-managed, or non-GitHub repos and continues after per-repo failures. | `--workspace `, `--manifest `, `--dry-run`, `--apply`, `--yes` | | `basectl workspace setup` | Set up eligible repositories from a workspace manifest in manifest order by delegating to each repository's local `basectl setup` command. Skips ineligible repositories, continues after per-repo failures, and reports setup/skipped/failed counts. | `--workspace `, `--manifest `, `--dry-run`, `--yes` | @@ -700,8 +700,11 @@ across the existing repositories in manifest order, then run specific manifest repositories; the names are validated before any Git operation and output remains in manifest order. Update never clones, resets, or changes the workspace manifest. It continues after individual Git failures, reports -updated/unchanged/skipped/failed counts, skips missing optional repositories, -and treats missing required repositories as failures. If the manifest points at +updated/unchanged/skipped/blocked/failed counts, skips missing optional +repositories, and treats missing or unsafe required repositories as blocked. +Git-registered linked worktrees nested under a primary checkout are excluded +from that primary root's untracked-file check; arbitrary untracked files and +real primary-root edits still block the update. If the manifest points at the active `BASE_HOME/base` checkout, that checkout is eligible for update after the same read-only preflight checks as other selected roots; a separate workspace checkout of `base` is eligible under the same policy. Text output uses a stable diff --git a/docs/schemas/workspace-update.json b/docs/schemas/workspace-update.json index 6347ab2b..dfb7b6ae 100644 --- a/docs/schemas/workspace-update.json +++ b/docs/schemas/workspace-update.json @@ -116,6 +116,11 @@ "type": "integer", "minimum": 0 }, + "blocked": { + "type": "integer", + "minimum": 0, + "description": "Required repositories safely refused before a pull because they were missing or failed preflight." + }, "failed": { "type": "integer", "minimum": 0 diff --git a/docs/workspace-manifest.md b/docs/workspace-manifest.md index 286f61b7..7df223f6 100644 --- a/docs/workspace-manifest.md +++ b/docs/workspace-manifest.md @@ -333,18 +333,21 @@ Update walks the manifest in order and runs `git pull --ff-only` in each present repository. Use `--repos name[,name...]` to select a subset; selections are validated before any Git operation and output remains in manifest order. It continues after individual failures and reports updated, unchanged, skipped, -and failed counts. Before pulling, each manifest root is checked for a clean +blocked, and failed counts. Before pulling, each manifest root is checked for a clean working tree, its configured or remote default branch, and an upstream tracking branch. Dirty roots, detached or non-default branches, missing upstreams, and -linked PR worktrees are reported as skipped preflight failures with the exact +linked PR worktrees are reported as blocked preflight refusals with the exact checkout path and recovery guidance; Base never stashes, resets, switches, or -pulls those checkouts. The JSON result includes a `preflight` reason array for -machine-readable handling. Dry runs perform the same read-only preflight and -show unsafe roots as skipped rather than presenting them as planned pulls; no -pulls are performed. Missing optional repositories are skipped; missing -required repositories are failures. Unsafe preflight skips for optional -repositories are non-fatal; required repository skips contribute to the -failure count. The command never clones, resets, +pulls those checkouts. Git-registered linked worktrees nested under a primary +checkout are excluded from that primary root's untracked-file check; arbitrary +untracked files and real primary-root edits remain blocking. The JSON result +includes a `preflight` reason array for machine-readable handling. Dry runs +perform the same read-only preflight and show unsafe roots as blocked rather +than presenting them as planned pulls; no pulls are performed. Missing optional +repositories are skipped; missing required repositories are blocked. Unsafe +preflight skips for optional repositories are non-fatal; required repository +blocks preserve a nonzero exit status and contribute to `blocked`, not `failed`. +Actual `git pull` errors contribute to `failed`. The command never clones, resets, force-updates, or refreshes the manifest. When the manifest's `base` path is the active `BASE_HOME` checkout, it is eligible for update after the same read-only preflight checks as other selected roots. A separate workspace checkout of `base` is also From 73a8c5ca6fd9cb11896f9db101dfd8af86ed24a6 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:34:05 +0530 Subject: [PATCH 2/3] fix(workspace): close review gaps in update preflight --- cli/bash/commands/basectl/README.md | 12 +++---- .../commands/basectl/subcommands/workspace.sh | 8 +++-- cli/bash/commands/basectl/tests/help.bats | 4 +++ .../tests/test_workspace_update.py | 34 ++++++++++++++++++- cli/python/base_projects/workspace_update.py | 25 ++++++++++---- docs/schemas/workspace-update.json | 2 +- 6 files changed, 68 insertions(+), 17 deletions(-) diff --git a/cli/bash/commands/basectl/README.md b/cli/bash/commands/basectl/README.md index 87cdc97a..a4a11370 100644 --- a/cli/bash/commands/basectl/README.md +++ b/cli/bash/commands/basectl/README.md @@ -294,12 +294,12 @@ such command directories exist. Optional utility CLIs such as `caff` and - `basectl workspace update` preflights manifest roots, then runs `git pull --ff-only` serially across safe present repositories in manifest order. It supports `--dry-run` with the same read-only preflight, - continues after individual failures, skips - dirty, non-default, non-tracking, or linked-worktree roots with recovery - guidance, skips missing optional repositories, treats missing required - repositories as failures, and includes the active `BASE_HOME` checkout when - it is the manifest's `base` target. It never stashes, resets, switches, or - updates linked PR worktrees implicitly. + continues after individual failures, blocks required roots that fail + preflight, skips missing or unsafe optional repositories with recovery + guidance, and includes the active `BASE_HOME` checkout when it is the + manifest's `base` target. Registered linked worktrees nested under a primary + root are ignored only for that root's untracked-file check. It never stashes, + resets, switches, or updates linked PR worktrees implicitly. - `basectl workspace configure` previews the existing `basectl repo configure` repair path by default across discovered Base-managed projects, or across present Base-managed repositories from a configured or explicit workspace diff --git a/cli/bash/commands/basectl/subcommands/workspace.sh b/cli/bash/commands/basectl/subcommands/workspace.sh index a3dc7441..c4da38b1 100644 --- a/cli/bash/commands/basectl/subcommands/workspace.sh +++ b/cli/bash/commands/basectl/subcommands/workspace.sh @@ -89,9 +89,11 @@ Options: -h, --help Show this help text. Preflight manifest roots, then run git pull --ff-only across safe existing -repositories in manifest order. Dirty, non-default, non-tracking, and linked -worktree roots are skipped with recovery guidance; no checkout is modified -implicitly. Dry runs perform the same read-only preflight without pulling. +repositories in manifest order. Required roots that fail preflight are blocked; +optional roots are skipped with recovery guidance. Registered linked worktrees +nested under a primary root are ignored only for that root's untracked-file +check; no checkout is modified implicitly. Dry runs perform the same read-only +preflight without pulling. EOF } diff --git a/cli/bash/commands/basectl/tests/help.bats b/cli/bash/commands/basectl/tests/help.bats index 1e8ccdf3..4e9c9443 100644 --- a/cli/bash/commands/basectl/tests/help.bats +++ b/cli/bash/commands/basectl/tests/help.bats @@ -385,6 +385,10 @@ PY [ "$status" -eq 0 ] [[ "$output" == *"basectl workspace update [options]"* ]] + [[ "$output" == *"Required roots that fail preflight are blocked"* ]] + [[ "$output" == *"optional roots are skipped"* ]] + [[ "$output" == *"Registered linked worktrees"* ]] + [[ "$output" == *"nested under a primary root"* ]] workspace_update_row="$(grep -F '| `basectl workspace update` |' "$command_reference")" [[ "$workspace_update_row" == *"Run \`git pull --ff-only\` across present repositories in manifest order"* ]] diff --git a/cli/python/base_projects/tests/test_workspace_update.py b/cli/python/base_projects/tests/test_workspace_update.py index 31142e31..5f8268c3 100644 --- a/cli/python/base_projects/tests/test_workspace_update.py +++ b/cli/python/base_projects/tests/test_workspace_update.py @@ -4,6 +4,7 @@ import json import re +import shutil import subprocess import tempfile import unittest @@ -672,7 +673,7 @@ def test_workspace_update_ignores_registered_nested_worktree_paths(self) -> None with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) primary = root / "primary" - nested = primary / "base-cli-worktrees" / "242-api-freeze" + nested = primary / "base-cli-worktrees" / "242-api-*?" primary.mkdir() subprocess.run(["git", "init", "--initial-branch=main", str(primary)], check=True, capture_output=True) subprocess.run(["git", "-C", str(primary), "config", "user.name", "Workspace Test"], check=True) @@ -710,6 +711,31 @@ def test_workspace_update_ignores_registered_nested_worktree_paths(self) -> None ) ) + sibling = nested.parent / "242-api-other" + sibling.mkdir() + (sibling / "unrelated.txt").write_text("unrelated edit\n", encoding="utf-8") + literal_pathspec_result = workspace_update.preflight_workspace_update_target( + workspace_update.WorkspaceUpdateTarget( + name="demo", + root=primary, + action="pull", + default_branch="main", + ) + ) + + shutil.rmtree(sibling) + shutil.rmtree(nested) + nested.mkdir(parents=True) + (nested / "stale-registration.txt").write_text("stale registration edit\n", encoding="utf-8") + stale_registration_result = workspace_update.preflight_workspace_update_target( + workspace_update.WorkspaceUpdateTarget( + name="demo", + root=primary, + action="pull", + default_branch="main", + ) + ) + (primary / "primary-change.txt").write_text("real primary edit\n", encoding="utf-8") dirty_result = workspace_update.preflight_workspace_update_target( workspace_update.WorkspaceUpdateTarget( @@ -721,8 +747,14 @@ def test_workspace_update_ignores_registered_nested_worktree_paths(self) -> None ) self.assertIsNone(result) + self.assertIsNotNone(literal_pathspec_result) + self.assertIsNotNone(stale_registration_result) self.assertIsNotNone(dirty_result) + assert literal_pathspec_result is not None + assert stale_registration_result is not None assert dirty_result is not None + self.assertEqual(literal_pathspec_result.preflight, ("dirty",)) + self.assertEqual(stale_registration_result.preflight, ("dirty",)) self.assertEqual(dirty_result.preflight, ("dirty",)) def test_workspace_update_reports_preflight_diagnostics_in_text(self) -> None: diff --git a/cli/python/base_projects/workspace_update.py b/cli/python/base_projects/workspace_update.py index bd2371c0..ee500333 100644 --- a/cli/python/base_projects/workspace_update.py +++ b/cli/python/base_projects/workspace_update.py @@ -516,7 +516,7 @@ def workspace_update_git_status( filtered_status_arguments = [*status_arguments, "--", "."] filtered_status_arguments.extend( - f":(exclude){path.relative_to(target_root).as_posix()}" for path in nested_worktrees + f":(exclude,literal){path.relative_to(target_root).as_posix()}" for path in nested_worktrees ) filtered_status = workspace_update_git_status_for_arguments(target, filtered_status_arguments) if isinstance(filtered_status, WorkspaceUpdateResult): @@ -573,11 +573,24 @@ def workspace_update_nested_worktree_paths(target: WorkspaceUpdateTarget) -> tup def parse_workspace_worktree_paths(output: str) -> tuple[Path, ...]: - return tuple( - Path(line.removeprefix("worktree ")).resolve() - for line in output.splitlines() - if line.startswith("worktree ") and line.removeprefix("worktree ").strip() - ) + paths: list[Path] = [] + block: list[str] = [] + for line in (*output.splitlines(), ""): + if line.strip(): + block.append(line) + continue + + worktree_value = next( + (entry.removeprefix("worktree ").strip() for entry in block if entry.startswith("worktree ")), + "", + ) + if worktree_value and not any(entry.startswith("prunable ") for entry in block): + path = Path(worktree_value).resolve() + if path.is_dir(): + paths.append(path) + block = [] + + return tuple(paths) def workspace_update_preflight_state( diff --git a/docs/schemas/workspace-update.json b/docs/schemas/workspace-update.json index dfb7b6ae..487b437d 100644 --- a/docs/schemas/workspace-update.json +++ b/docs/schemas/workspace-update.json @@ -98,7 +98,7 @@ }, "counts": { "type": "object", - "required": ["planned", "updated", "unchanged", "skipped", "failed"], + "required": ["planned", "updated", "unchanged", "skipped", "blocked", "failed"], "properties": { "planned": { "type": "integer", From c9f9d31f9f04c601592e08da398cdce38d978ba5 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:27:59 +0530 Subject: [PATCH 3/3] fix(security): upgrade pylint past vulnerability --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 2d1602a2..b80ea204 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -pylint==3.3.9 +pylint==4.0.9 click==8.4.1 base-cli==0.5.1 PyYAML==6.0.3