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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .ai-context/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init|clone|check|configure|agent-guidance|installer-template>` -
create repository baselines, clone GitHub repositories into the configured
Expand Down
12 changes: 6 additions & 6 deletions cli/bash/commands/basectl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions cli/bash/commands/basectl/subcommands/workspace.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 4 additions & 0 deletions cli/bash/commands/basectl/tests/help.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"* ]]
Expand Down
141 changes: 127 additions & 14 deletions cli/python/base_projects/tests/test_workspace_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import re
import shutil
import subprocess
import tempfile
import unittest
Expand Down Expand Up @@ -355,12 +356,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 = (
Expand Down Expand Up @@ -469,9 +473,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:
Expand Down Expand Up @@ -527,7 +531,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 "))
Expand Down Expand Up @@ -634,6 +638,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,
Expand All @@ -653,6 +669,94 @@ 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-*?"
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",
)
)

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(
name="demo",
root=primary,
action="pull",
default_branch="main",
)
)

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:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
Expand Down Expand Up @@ -691,9 +795,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:
Expand Down Expand Up @@ -740,7 +844,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:
Expand Down Expand Up @@ -824,7 +931,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],
[
Expand Down Expand Up @@ -960,7 +1067,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:
Expand Down Expand Up @@ -1021,7 +1131,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:
Expand Down Expand Up @@ -1061,7 +1174,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:
Expand Down Expand Up @@ -1102,9 +1215,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:
Expand Down
Loading
Loading