diff --git a/CHANGELOG.md b/CHANGELOG.md index c32fb0e..c193437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Add persistent integer stack IDs and `.` container IDs, shown + in a new `ID` column in `wco ps` and `wco stacks` and in their JSON output. Stack + 1 is always the shared project; isolated worktrees are numbered from 2 and keep + their ID, recorded in `ports.json`, until the worktree is removed. +- Accept an ID directly after a Docker Compose command to target another stack, or + a single container inside it, without changing directory — `wco down 2`, + `wco --isolated down 2`, `wco restart 2.1`, `wco logs 2.1 -f`, `wco exec 2.1 sh`. + A container ID resolves to its Compose service name; `down` acts on whole stacks + and rejects one. + +### Changed + +- Bump the `ports.json` state format to version 2, adding recorded stack IDs. + Version 1 files are read and upgraded in place on the next write. + +### Fixed + +- Resolve missing worktree-relative build contexts from the central WCO workspace when the same + path exists there, while preserving active-worktree resolution for bind mounts and build + contexts that are present in the checkout. + ## [1.2.1] - 2026-08-03 ### Changed diff --git a/README.md b/README.md index 78c4fa1..854c89c 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,9 @@ Things worth knowing: - Initialization never changes the Compose file. - Relative Compose paths such as `./` resolve from the **active worktree**, and Docker Compose loads `.env` from that worktree for interpolation. +- If a relative build context is absent from the active worktree but the same path exists beside + `.wco.toml`, WCO uses that central workspace path. This supports shared Dockerfiles such as + `build: { context: ./docker/nginx }` without changing worktree-relative bind mounts. - Declare parameterized published ports under `[isolation.ports]`. - Existing `.wco.toml` files are not replaced unless `--force` is supplied. @@ -105,7 +108,7 @@ wco down ``` **Output.** Interactive commands use colored, terminal-aware output. `wco ps` presents a -responsive `NAME`, `STATUS`, `WORKTREE`, and `BRANCH` table with status highlighting, and +responsive `ID`, `NAME`, `STATUS`, `WORKTREE`, and `BRANCH` table with status highlighting, and supports Compose filters, service arguments, `--all`, `--status`, `--orphans`, and `--no-trunc`. When output is redirected — or when `--format`, `--quiet`, or `--services` is supplied — WCO delegates the output to Docker Compose unchanged. Set [`NO_COLOR`](https://no-color.org/) to @@ -128,6 +131,26 @@ wco stacks --format json workspace's shared project or the isolated project derived from their recorded worktree, so stacks belonging to other projects are never listed. +**Targeting a stack by ID.** Every row carries an `ID` of the form `.`. Stack 1 +is always the shared project; isolated worktrees are numbered from 2 and keep their ID until the +worktree is removed, so an ID is safe to script against. Pass one directly after a Compose command +to act on that stack — or that single container — without changing directory: + +```bash +wco down 2 # bring down stack 2, wherever you are +wco restart 2.1 # restart just that container's service +wco logs 2.1 -f # ...the ID comes first, then the flags +wco exec 2.1 sh +``` + +A container ID resolves to its Compose service name, so it works with any command that accepts +services. `down` acts on whole stacks and rejects a container ID — use `stop` for one container. +The ID is only read in the position immediately after the Compose command, which keeps numeric +option values such as `wco logs --tail 20` untouched; the same rule means a service literally +named `2` cannot be targeted this way. Stack IDs are recorded alongside port slots in `ports.json` +(see [Port assignments and state](#port-assignments-and-state)) and are reused once the worktree +they name is gone. + **Where containers run.** The `WORKTREE` column reports the worktree each listed container was actually created from — not the one you are standing in — and is highlighted when the two differ. `BRANCH` shows that worktree's checked-out branch, or the short revision in parentheses when the @@ -311,7 +334,8 @@ needed and remain available for later commands such as `wco --isolated down`. ### Port assignments and state The first isolated worktree receives each base port plus one `port_step`, the next receives the -lowest available slot, and assignments remain stable in `ports.json`: +lowest available slot, and assignments remain stable in `ports.json`. The same file records each +worktree's stack ID, so both survive across invocations: | Platform | State file | | --- | --- | @@ -319,7 +343,9 @@ lowest available slot, and assignments remain stable in `ports.json`: | Native Windows | `%LOCALAPPDATA%\wco\ports.json` | Generated isolation overrides for container names and fixed ports use the adjacent `overrides/` -directory. +directory. Entries whose config file or worktree no longer exists are pruned on read, which frees +both the port slot and the stack ID for reuse. WCO upgrades an older `ports.json` in place the +first time it writes to it. > [!NOTE] > `wco` controls Docker Compose's project name, so `-p` and `--project-name` are intentionally diff --git a/src/wco/cli.py b/src/wco/cli.py index 0e9183e..eb0ca43 100644 --- a/src/wco/cli.py +++ b/src/wco/cli.py @@ -25,10 +25,14 @@ CONFIG_NAME = ".wco.toml" LEGACY_CONFIG_NAME = ".bcompose.toml" LEGACY_STATE_DIRECTORY = "bcompose" +STATE_VERSION = 2 +SHARED_STACK_ID = 1 +TARGET_RE = re.compile(r"^(\d+)(?:\.(\d+))?$") PROJECT_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") CONTAINER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") ENVIRONMENT_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") STARTUP_COMMANDS = ("up", "create", "start", "restart", "run", "watch") +BUILD_CONTEXT_COMMANDS = {"build", "up", "create", "run", "watch"} COMPOSE_COMMANDS = { "attach", "build", @@ -140,17 +144,30 @@ class Invocation: compose_command: str | None project_name: str instance_name: str + stack_id: int slot: int | None ports: dict[str, int] environment: dict[str, str] +@dataclass(frozen=True) +class Target: + """A wco ID naming a stack, and optionally one container inside it.""" + + stack: int + container: int | None + + def __str__(self) -> str: + return f"{self.stack}" if self.container is None else f"{self.stack}.{self.container}" + + @dataclass(frozen=True) class IsolationOverride: path: Path names: dict[str, str] ports: dict[str, tuple[dict[str, object], ...]] rewritten_ports: int + build_contexts: dict[str, str] def _table(data: Mapping[str, object], name: str) -> Mapping[str, object]: @@ -394,6 +411,43 @@ def compose_command(arguments: Sequence[str]) -> str | None: return arguments[index] if index is not None else None +def split_target(arguments: Sequence[str]) -> tuple[list[str], Target | None]: + """Strip a '[.]' wco ID from just after the Compose command. + + Only that one position is examined, so numeric option values and service + names elsewhere are passed through untouched. + """ + command_index = _compose_command_index(arguments) + if command_index is None: + return list(arguments), None + position = command_index + 1 + if position >= len(arguments): + return list(arguments), None + token = arguments[position] + match = TARGET_RE.match(token) + if match is None: + return list(arguments), None + stack = int(match.group(1)) + container = None if match.group(2) is None else int(match.group(2)) + if stack < 1 or container == 0: + raise WcoError(f"'{token}' is not a valid wco ID; IDs start at 1.") + remaining = [*arguments[:position], *arguments[position + 1 :]] + return remaining, Target(stack=stack, container=container) + + +def _insert_after_command(arguments: Sequence[str], extra: Sequence[str]) -> list[str]: + if not extra: + return list(arguments) + command_index = _compose_command_index(arguments) + if command_index is None: + raise WcoError("Cannot locate the Docker Compose command.") + return [ + *arguments[: command_index + 1], + *extra, + *arguments[command_index + 1 :], + ] + + def reject_reserved_arguments(arguments: Sequence[str]) -> None: for index, argument in enumerate(arguments): if argument in RESERVED_PROJECT_OPTIONS: @@ -630,17 +684,23 @@ def __init__( def _read(self) -> dict[str, object]: if not self.path.exists(): - return {"version": 1, "assignments": []} + return {"version": STATE_VERSION, "assignments": [], "stacks": []} try: data = json.loads(self.path.read_text()) except (OSError, json.JSONDecodeError) as exc: raise WcoError(f"Cannot read port state '{self.path}': {exc}") from exc if ( not isinstance(data, dict) - or data.get("version") != 1 + or data.get("version") not in {1, STATE_VERSION} or not isinstance(data.get("assignments"), list) ): raise WcoError(f"Port state '{self.path}' has an unsupported format.") + if data.get("version") == 1: + # Version 1 predates stack IDs; the rest of the file is unchanged. + data["version"] = STATE_VERSION + data["stacks"] = [] + elif not isinstance(data.get("stacks"), list): + raise WcoError(f"Port state '{self.path}' has an unsupported format.") return data def _write(self, data: Mapping[str, object]) -> None: @@ -664,36 +724,41 @@ def _locked(self) -> AbstractContextManager[None]: @staticmethod def _upgrade_config_paths(data: dict[str, object]) -> bool: - assignments = data["assignments"] - assert isinstance(assignments, list) changed = False - for item in assignments: - if not isinstance(item, dict) or not isinstance(item.get("config"), str): - continue - config_path = Path(item["config"]) - if config_path.name != LEGACY_CONFIG_NAME: - continue - replacement = config_path.with_name(CONFIG_NAME) - if replacement.is_file(): - item["config"] = str(replacement.resolve()) - changed = True + for key in ("assignments", "stacks"): + entries = data[key] + assert isinstance(entries, list) + for item in entries: + if not isinstance(item, dict) or not isinstance(item.get("config"), str): + continue + config_path = Path(item["config"]) + if config_path.name != LEGACY_CONFIG_NAME: + continue + replacement = config_path.with_name(CONFIG_NAME) + if replacement.is_file(): + item["config"] = str(replacement.resolve()) + changed = True return changed @staticmethod - def _prune(data: dict[str, object]) -> bool: - assignments = data["assignments"] - assert isinstance(assignments, list) - kept = [ - item - for item in assignments - if isinstance(item, dict) + def _live(item: object) -> bool: + return ( + isinstance(item, dict) and isinstance(item.get("config"), str) and isinstance(item.get("worktree"), str) and Path(item["config"]).is_file() and Path(item["worktree"]).is_dir() - ] - changed = len(kept) != len(assignments) - data["assignments"] = kept + ) + + @classmethod + def _prune(cls, data: dict[str, object]) -> bool: + changed = False + for key in ("assignments", "stacks"): + entries = data[key] + assert isinstance(entries, list) + kept = [item for item in entries if cls._live(item)] + changed = changed or len(kept) != len(entries) + data[key] = kept return changed @staticmethod @@ -883,6 +948,75 @@ def reallocate(self, config: WorkspaceConfig, worktree: Path) -> tuple[int, dict for name, value in dict(assignment["ports"]).items() } + @staticmethod + def _stack_entries( + data: Mapping[str, object], config: WorkspaceConfig + ) -> list[dict[str, object]]: + stacks = data["stacks"] + assert isinstance(stacks, list) + return [ + item + for item in stacks + if isinstance(item, dict) + and item.get("config") == str(config.path) + and isinstance(item.get("id"), int) + ] + + def stack_id(self, config: WorkspaceConfig, worktree: Path) -> int: + """The persistent integer ID of this worktree's isolated stack. + + IDs start at SHARED_STACK_ID + 1: the shared project is always ID 1 and + is never recorded. The lowest free ID is reused once _prune drops a + worktree that no longer exists. + """ + with self._locked(): + data = self._read() + changed = self._upgrade_config_paths(data) + changed = self._prune(data) or changed + entries = self._stack_entries(data, config) + for item in entries: + if item.get("worktree") == str(worktree): + if changed: + self._write(data) + return int(item["id"]) + taken = {int(item["id"]) for item in entries} + identifier = SHARED_STACK_ID + 1 + while identifier in taken: + identifier += 1 + stacks = data["stacks"] + assert isinstance(stacks, list) + stacks.append( + { + "config": str(config.path), + "worktree": str(worktree), + "id": identifier, + } + ) + self._write(data) + return identifier + + def list_stack_ids(self, config: WorkspaceConfig) -> dict[Path, int]: + """Every recorded stack ID for this workspace, as stored.""" + with self._locked(): + data = self._read() + changed = self._upgrade_config_paths(data) + changed = self._prune(data) or changed + listed = { + Path(str(item["worktree"])): int(item["id"]) + for item in self._stack_entries(data, config) + } + if changed: + self._write(data) + return listed + + def worktree_for_stack_id( + self, config: WorkspaceConfig, stack_id: int + ) -> Path | None: + for worktree, identifier in self.list_stack_ids(config).items(): + if identifier == stack_id: + return worktree + return None + def migrate_legacy_state(environ: Mapping[str, str] | None = None) -> Path: destination = state_file(environ) @@ -1084,9 +1218,15 @@ def _port_mappings( def _write_isolation_override(path: Path, override: IsolationOverride) -> None: content = ["services:"] - services = sorted(set(override.names) | set(override.ports)) + services = sorted( + set(override.names) | set(override.ports) | set(override.build_contexts) + ) for service in services: content.append(f" {json.dumps(service)}:") + build_context = override.build_contexts.get(service) + if build_context is not None: + content.append(" build:") + content.append(f" context: {json.dumps(build_context)}") container_name = override.names.get(service) if container_name is not None: content.append(f" container_name: {json.dumps(container_name)}") @@ -1182,31 +1322,75 @@ def _render_compose_config( rendered = json.loads(result.stdout) except json.JSONDecodeError as exc: raise WcoError( - f"Docker Compose returned invalid JSON during isolation preparation: {exc}" + f"Docker Compose returned invalid JSON while preparing overrides: {exc}" ) from exc if not isinstance(rendered, dict): raise WcoError("Docker Compose returned an invalid configuration model.") return rendered +def _workspace_build_context_mappings( + invocation: Invocation, + rendered: Mapping[str, object], +) -> dict[str, str]: + """Find relative build contexts that only exist in the central workspace. + + Compose resolves every relative path from the project directory. WCO uses the + active worktree as that directory so relative bind mounts keep targeting the + selected checkout. Central Docker build assets are the exception: when the + worktree-relative context is absent but the same path exists beside the WCO + configuration, override just that context with its absolute workspace path. + """ + if invocation.worktree == invocation.config.workspace: + return {} + services = rendered.get("services", {}) + if not isinstance(services, dict): + return {} + mappings: dict[str, str] = {} + for service, definition in services.items(): + if not isinstance(service, str) or not isinstance(definition, dict): + continue + build = definition.get("build") + if not isinstance(build, dict): + continue + context = build.get("context") + if not isinstance(context, str): + continue + rendered_path = Path(context) + if not rendered_path.is_absolute() or rendered_path.exists(): + continue + try: + # resolve() first: the worktree is already resolved, so an + # unresolved context under a symlinked parent (/var on macOS, a + # short 8.3 path on Windows) would otherwise never match it. + relative = rendered_path.resolve().relative_to(invocation.worktree) + except ValueError: + continue + workspace_path = invocation.config.workspace / relative + if workspace_path.exists(): + mappings[service] = str(workspace_path.resolve()) + return mappings + + def prepare_isolation_override( invocation: Invocation, run_process: Callable[..., subprocess.CompletedProcess[str]] | None = None, directory: Path | None = None, ) -> IsolationOverride | None: - if ( - not invocation.isolated - or not ( - invocation.config.isolation.rewrite_container_names - or invocation.config.isolation.rewrite_ports - ) - ): + rewrites_isolation = invocation.isolated and ( + invocation.config.isolation.rewrite_container_names + or invocation.config.isolation.rewrite_ports + ) + checks_build_contexts = invocation.compose_command in BUILD_CONTEXT_COMMANDS + if not rewrites_isolation and not checks_build_contexts: return None run_process = run_process or subprocess.run global_arguments = _compose_global_arguments(invocation.compose_args) if _uses_stdin_compose_file(global_arguments): + if not rewrites_isolation: + return None raise WcoError( - "Isolation rewriting cannot be used with '--file -'. " + "Generated overrides cannot be used with '--file -'. " "Use a named Compose file instead." ) rendered = _render_compose_config( @@ -1215,19 +1399,27 @@ def prepare_isolation_override( run_process, all_profiles=True, ) + build_contexts = _workspace_build_context_mappings(invocation, rendered) names = ( _container_name_mappings(invocation, rendered) - if invocation.config.isolation.rewrite_container_names + if invocation.isolated + and invocation.config.isolation.rewrite_container_names else {} ) - ports, rewritten_ports = _port_mappings(invocation, rendered) - if not names and not ports: + ports, rewritten_ports = ( + _port_mappings(invocation, rendered) + if invocation.isolated and invocation.config.isolation.rewrite_ports + else ({}, 0) + ) + if not names and not ports and not build_contexts: return None path = _container_override_path( invocation, (directory or override_directory()).resolve(), ) - override = IsolationOverride(path, names, ports, rewritten_ports) + override = IsolationOverride( + path, names, ports, rewritten_ports, build_contexts + ) _write_isolation_override(path, override) return override @@ -1254,9 +1446,12 @@ def _render_environment( return environment -def _docker_container_ids(project_name: str) -> list[str]: +def _docker_container_ids( + project_name: str, + run_process: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> list[str]: try: - result = subprocess.run( + result = run_process( [ "docker", "ps", @@ -1397,14 +1592,18 @@ def prepare_invocation( if isolated: project_name = _isolated_name(config.project_name, worktree) instance_name = _isolated_name(config.instance_name, worktree) + # Stack IDs are allocated for every isolated worktree, whether or not + # the workspace declares isolated ports. + store = store or default_port_store() + stack_id = store.stack_id(config, worktree) if config.isolation.ports: - store = store or default_port_store() slot, ports = store.get_or_allocate(config, worktree) else: slot, ports = None, {} else: project_name = config.project_name instance_name = config.instance_name + stack_id = SHARED_STACK_ID slot = None ports = dict(config.isolation.ports) @@ -1419,6 +1618,7 @@ def prepare_invocation( compose_command=command, project_name=project_name, instance_name=instance_name, + stack_id=stack_id, slot=slot, ports=ports, environment=environment, @@ -1722,6 +1922,99 @@ def _container_label(inspection: Mapping[str, object], label: str) -> str: return value if isinstance(value, str) else "" +def _container_number(inspection: Mapping[str, object]) -> int: + try: + return int(_container_label(inspection, "com.docker.compose.container-number")) + except ValueError: + return 0 + + +def _container_sort_key(service: str, number: int, name: str) -> tuple[str, int, str]: + """The ordering that assigns container sub-IDs, used for listing and lookup.""" + return (service, number, name) + + +def _stack_containers( + project_name: str, + run_process: Callable[..., subprocess.CompletedProcess[str]], + output: Output, +) -> list[str]: + """Service names in one Compose project, in container sub-ID order.""" + ids = _docker_container_ids(project_name, run_process) + inspections = _inspect_containers(ids, run_process, output) + entries: list[tuple[tuple[str, int, str], str]] = [] + for container_id in ids: + inspection = inspections.get(container_id) + if inspection is None: + continue + service = _container_label(inspection, "com.docker.compose.service") + name = str(inspection.get("Name") or container_id[:12]).lstrip("/") + entries.append( + (_container_sort_key(service, _container_number(inspection), name), service) + ) + entries.sort(key=lambda entry: entry[0]) + return [service for _key, service in entries] + + +def _resolve_target( + target: Target, + command: str | None, + isolated: bool, + cwd: Path | None, + output: Output, + run_process: Callable[..., subprocess.CompletedProcess[str]], +) -> tuple[Path, bool, list[str]]: + """Map a wco ID to the worktree, mode, and extra Compose arguments it names.""" + worktree = resolve_worktree((cwd or Path.cwd()).resolve()) + config = load_config(find_config(worktree)) + + if target.stack == SHARED_STACK_ID: + if isolated: + raise WcoError( + f"Stack {SHARED_STACK_ID} is this workspace's shared stack; " + "drop '--isolated'." + ) + target_worktree, target_isolated = worktree, False + project_name = config.project_name + else: + found = default_port_store().worktree_for_stack_id(config, target.stack) + if found is None: + raise WcoError( + f"No stack has ID {target.stack} in this workspace. " + "Run 'wco stacks' to list them." + ) + if not found.is_dir(): + raise WcoError( + f"Stack {target.stack} refers to '{found}', which no longer exists. " + "Remove its containers with 'docker compose -p " + f"{_isolated_name(config.project_name, found)} down'." + ) + target_worktree, target_isolated = found, True + project_name = _isolated_name(config.project_name, found) + + if target.container is None: + return target_worktree, target_isolated, [] + + if command == "down": + raise WcoError( + f"'down' targets a whole stack. Use 'wco stop {target}' to stop one " + f"container, or 'wco down {target.stack}' to bring the stack down." + ) + services = _stack_containers(project_name, run_process, output) + if not services: + raise WcoError( + f"Stack {target.stack} has no containers, so '{target}' cannot be " + "resolved. Start it with 'wco up -d' first." + ) + if target.container > len(services): + raise WcoError( + f"Stack {target.stack} has {len(services)} container(s); " + f"'{target}' is out of range (valid: {target.stack}.1-" + f"{target.stack}.{len(services)})." + ) + return target_worktree, target_isolated, [services[target.container - 1]] + + def _stack_mode(config: WorkspaceConfig, project: str, worktree: str) -> str | None: if project == config.project_name: return "shared" @@ -1738,8 +2031,10 @@ def _stack_rows( ids: Sequence[str], now: datetime, run_process: Callable[..., subprocess.CompletedProcess[str]], + stack_ids: Mapping[str, int] | None = None, ) -> list[StackRow]: - rows: list[StackRow] = [] + stack_ids = {} if stack_ids is None else stack_ids + ordered: list[tuple[tuple[int, str, int, str], StackRow]] = [] branches: dict[str, str] = {} for container_id in ids: inspection = inspections.get(container_id) @@ -1754,22 +2049,70 @@ def _stack_rows( name = str(inspection.get("Name") or container_id[:12] or "-").lstrip("/") if worktree not in branches: branches[worktree] = _worktree_branch(worktree, run_process) - rows.append( - StackRow( - name=name, - status=status, - state=state, - health=health, - worktree=worktree, - branch=branches[worktree], - mode=mode, - project=project, + stack_id = ( + SHARED_STACK_ID if mode == "shared" else stack_ids.get(worktree, 0) + ) + service = _container_label(inspection, "com.docker.compose.service") + ordered.append( + ( + (stack_id, *_container_sort_key( + service, _container_number(inspection), name + )), + StackRow( + name=name, + status=status, + state=state, + health=health, + worktree=worktree, + branch=branches[worktree], + mode=mode, + project=project, + id=str(stack_id), + ), ) ) - rows.sort(key=lambda row: (row.mode != "shared", row.worktree, row.name)) + ordered.sort(key=lambda entry: entry[0]) + + # Sub-IDs are per stack, numbered in the order the rows are displayed. + rows: list[StackRow] = [] + position = 0 + previous: str | None = None + for _key, row in ordered: + position = position + 1 if row.id == previous else 1 + previous = row.id + rows.append(replace(row, id="-" if row.id == "0" else f"{row.id}.{position}")) return rows +def _assign_stack_ids( + config: WorkspaceConfig, + inspections: Mapping[str, Mapping[str, object]], + ids: Sequence[str], +) -> dict[str, int]: + """Stack IDs keyed by worktree, allocating one for each isolated stack seen. + + 'wco stacks' is where users learn an ID, so a stack that is running but has + never been through prepare_invocation still gets one here. + """ + store = default_port_store() + assigned = { + str(worktree): identifier + for worktree, identifier in store.list_stack_ids(config).items() + } + for container_id in ids: + inspection = inspections.get(container_id) + if inspection is None: + continue + worktree = _container_worktree(inspection) + project = _container_label(inspection, "com.docker.compose.project") + if _stack_mode(config, project, worktree) != "isolated": + continue + if worktree in assigned or not Path(worktree).is_dir(): + continue + assigned[worktree] = store.stack_id(config, Path(worktree)) + return assigned + + def _is_down_all(arguments: Sequence[str]) -> bool: """True for 'wco --isolated down --all', which Compose itself has no flag for.""" command_index = _compose_command_index(arguments) @@ -1783,10 +2126,12 @@ def _isolated_worktrees( run_process: Callable[..., subprocess.CompletedProcess[str]], output: Output, ) -> list[Path]: - """Every worktree with an isolated slot or an isolated container.""" - worktrees: list[Path] = [ - worktree for worktree, _slot, _ports in default_port_store().list_assignments(config) - ] + """Every worktree with a stack ID, an isolated slot, or an isolated container.""" + store = default_port_store() + worktrees: list[Path] = list(store.list_stack_ids(config)) + for worktree, _slot, _ports in store.list_assignments(config): + if worktree not in worktrees: + worktrees.append(worktree) ids = _compose_container_ids(run_process, include_stopped=True) inspections = _inspect_containers(ids, run_process, output) for container_id in ids: @@ -1861,7 +2206,8 @@ def _stacks_command( config = load_config(find_config(worktree)) ids = _compose_container_ids(run_process, include_stopped=include_stopped) inspections = _inspect_containers(ids, run_process, output) - rows = _stack_rows(config, inspections, ids, now, run_process) + stack_ids = _assign_stack_ids(config, inspections, ids) + rows = _stack_rows(config, inspections, ids, now, run_process, stack_ids) if output_format == "json": _write_json( stdout, @@ -1870,6 +2216,8 @@ def _stacks_command( "project": config.project_name, "containers": [ { + "id": None if row.id == "-" else row.id, + "stack_id": None if row.id == "-" else int(row.id.split(".")[0]), "mode": row.mode, "project": row.project, "name": row.name, @@ -2078,22 +2426,32 @@ def _ps_rows( items: Sequence[Mapping[str, object]], inspections: Mapping[str, Mapping[str, object]], now: datetime, + stack_id: int | None = None, ) -> list[PsRow]: - rows: list[PsRow] = [] + ordered: list[tuple[tuple[str, int, str], PsRow]] = [] for item in items: container_id = str(item.get("ID") or "") inspection = inspections.get(container_id, {}) status, state, health = _container_status(item, inspection, now) - rows.append( - PsRow( - name=str(item.get("Name") or container_id[:12] or "-"), - status=status, - state=state, - health=health, - worktree=_container_worktree(inspection), - ) + name = str(item.get("Name") or container_id[:12] or "-") + service = str(item.get("Service") or "") or _container_label( + inspection, "com.docker.compose.service" ) - return rows + row = PsRow( + name=name, + status=status, + state=state, + health=health, + worktree=_container_worktree(inspection), + ) + ordered.append( + (_container_sort_key(service, _container_number(inspection), name), row) + ) + ordered.sort(key=lambda entry: entry[0]) + return [ + replace(row, id="-" if stack_id is None else f"{stack_id}.{position}") + for position, (_key, row) in enumerate(ordered, start=1) + ] def _inspect_containers( @@ -2145,6 +2503,7 @@ def _print_context( invocation.slot, len(override.names) if override is not None else 0, override.rewritten_ports if override is not None else 0, + stack_id=invocation.stack_id, current_worktree=invocation.worktree, ) @@ -2184,7 +2543,7 @@ def _run_pretty_ps( items = _parse_ps_json(result.stdout) ids = [str(item.get("ID")) for item in items if item.get("ID")] inspections = _inspect_containers(ids, run_process, output) - rows = _ps_rows(items, inspections, now) + rows = _ps_rows(items, inspections, now, invocation.stack_id) branches = _worktree_branches(rows, run_process) rows = [replace(row, branch=branches[row.worktree]) for row in rows] _print_context(output, invocation, override, None) @@ -2216,6 +2575,11 @@ def _run_pretty_ps( List every container this workspace owns, in the shared project and in each worktree's isolated project, with the worktree and branch it runs from. +The ID column shows each container's '.' address. Stack 1 is +always the shared project; isolated worktrees are numbered from 2 and keep +their ID until the worktree is removed. Pass an ID to a Compose command to +target that stack or container, e.g. 'wco down 2' or 'wco restart 2.1'. + Options: -a, --all Include stopped containers. --format FORMAT Select table or JSON output. @@ -2223,7 +2587,7 @@ def _run_pretty_ps( """ -HELP = """Usage: wco [--isolated] +HELP = """Usage: wco [--isolated] [ID] [docker compose arguments] wco init [--compose PATH] [--project NAME] [--force] wco ports [--all] [--format table|json] wco stacks [--all] [--format table|json] @@ -2232,6 +2596,12 @@ def _run_pretty_ps( Git worktree. The nearest .wco.toml at or above the worktree defines the Compose files, environment, validation rules, and isolated ports. +An optional ID directly after the Compose command targets another stack +without changing directory. 'wco stacks' lists the IDs: stack 1 is the shared +project, isolated worktrees are numbered from 2, and '2.1' addresses a single +container inside stack 2. Because only that one position is read as an ID, +put it before any flags: 'wco logs 2.1 -f', not 'wco logs -f 2.1'. + Options: --isolated Use a worktree-specific project name and persistent port slot. --format Select table or JSON output for wco ports and wco stacks. @@ -2247,6 +2617,9 @@ def _run_pretty_ps( wco --isolated up -d wco --isolated down wco --isolated down --all + wco down 2 + wco restart 2.1 + wco logs 2.1 -f wco ports show wco ports show --all wco stacks --all @@ -2298,6 +2671,19 @@ def run( if isolated and _is_down_all(arguments): return _isolated_down_all(arguments, cwd, output, run_process) + arguments, target = split_target(arguments) + if target is not None: + worktree, isolated, extra = _resolve_target( + target, + compose_command(arguments), + isolated, + cwd, + output, + run_process, + ) + cwd = worktree + arguments = _insert_after_command(arguments, extra) + invocation = prepare_invocation(arguments, isolated, cwd) override = prepare_isolation_override(invocation, run_process) is_startup = ( diff --git a/src/wco/presentation.py b/src/wco/presentation.py index 0517d08..209c5f3 100644 --- a/src/wco/presentation.py +++ b/src/wco/presentation.py @@ -87,6 +87,7 @@ class PsRow: health: str worktree: str = "-" branch: str = "-" + id: str = "-" @dataclass(frozen=True) @@ -257,11 +258,13 @@ def context( name_overrides: int = 0, port_overrides: int = 0, *, + stack_id: int | None = None, current_worktree: Path | None = None, ) -> None: mode = "isolated" if isolated else "shared" badge = Text.assemble( (mode, "magenta" if isolated else "cyan"), + (f" stack {stack_id}", LABEL_STYLE) if stack_id is not None else "", (f" slot {slot}", LABEL_STYLE) if slot is not None else "", ) self._heading(self.stderr, "WCO context") @@ -366,6 +369,7 @@ def ps( no_wrap = not no_trunc table.expand = True table.add_column("", no_wrap=True, width=1) + table.add_column("ID", no_wrap=True) table.add_column( "NAME", style="cyan", @@ -399,6 +403,7 @@ def ps( cells.append( ( Text(self._glyph(kind), style=style), + self._value(row.id, style="bold"), Text(row.name), self._status_lines(row.status, style), self._worktree_cell(row.worktree, current_worktree), @@ -425,6 +430,7 @@ def stacks( table = self._table() table.expand = True table.add_column("", no_wrap=True, width=1) + table.add_column("ID", no_wrap=True) table.add_column("MODE", no_wrap=True) table.add_column("NAME", style="cyan", overflow="ellipsis", no_wrap=True, ratio=4, min_width=6) table.add_column("STATUS", overflow="fold", ratio=3, min_width=7) @@ -443,6 +449,7 @@ def stacks( cells.append( ( Text(self._glyph(kind), style=style), + self._value(row.id, style="bold"), Text(row.mode, style="magenta" if row.mode == "isolated" else "cyan"), Text(row.name), self._status_lines(row.status, style), diff --git a/tests/test_cli.py b/tests/test_cli.py index 41c7cf8..f8be0e7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,6 +13,9 @@ from unittest.mock import patch from wco.cli import ( + SHARED_STACK_ID, + STATE_VERSION, + Target, WcoError, PortStore, _isolated_name, @@ -26,6 +29,7 @@ prepare_isolation_override, prepare_invocation, run, + split_target, state_file, ) @@ -82,6 +86,18 @@ def _allocate_port_worker( result_queue.put(("error", repr(exc), {})) +def wide_terminal(fixture: "WorkspaceFixture"): + """A terminal wide enough that no column is ellipsized. + + Temporary directories are far longer on some platforms than on others + (macOS resolves them under /private/var/folders/...), so a fixed COLUMNS + truncates the WORKTREE column there and not on Linux. Size it to the paths + the fixture actually produces instead. + """ + width = len(str(fixture.workspace.resolve())) + 140 + return patch.dict(os.environ, {"COLUMNS": str(width)}) + + class WorkspaceFixture: def __init__(self) -> None: self.temporary = tempfile.TemporaryDirectory() @@ -499,7 +515,7 @@ def test_show_all_lists_every_worktree_slot(self) -> None: with ( patch("wco.cli.default_port_store", return_value=self.store), - patch.dict(os.environ, {"COLUMNS": "200"}), + wide_terminal(self.fixture), ): result = run( ["ports", "show", "--all"], @@ -1214,6 +1230,99 @@ def unexpected_run(*_args, **_kwargs): self.assertEqual(result, 0) self.assertNotIn(".compose.yaml", " ".join(captured["args"])) + def test_shared_build_uses_context_from_central_workspace_when_missing_in_worktree( + self, + ) -> None: + central_context = self.fixture.workspace / "docker" / "nginx" + central_context.mkdir(parents=True) + (central_context / "Dockerfile").write_text("FROM scratch\n") + invocation = prepare_invocation(["build", "nginx"], False, self.fixture.main) + + def fake_run(command: list[str], **_kwargs): + rendered_context = self.fixture.main / "docker" / "nginx" + return subprocess.CompletedProcess( + command, + 0, + json.dumps( + { + "services": { + "nginx": { + "build": {"context": str(rendered_context)} + } + } + } + ), + "", + ) + + override = prepare_isolation_override( + invocation, + fake_run, + self.fixture.root / "overrides", + ) + + self.assertIsNotNone(override) + assert override is not None + self.assertEqual( + override.build_contexts, + {"nginx": str(central_context.resolve())}, + ) + self.assertIn( + f'context: {json.dumps(str(central_context.resolve()))}', + override.path.read_text(), + ) + command = build_invocation_command(invocation, override) + self.assertLess(command.index(str(override.path)), command.index("build")) + + def test_shared_build_keeps_an_existing_worktree_context(self) -> None: + central_context = self.fixture.workspace / "docker" / "nginx" + worktree_context = self.fixture.main / "docker" / "nginx" + central_context.mkdir(parents=True) + worktree_context.mkdir(parents=True) + invocation = prepare_invocation(["build", "nginx"], False, self.fixture.main) + + def fake_run(command: list[str], **_kwargs): + return subprocess.CompletedProcess( + command, + 0, + json.dumps( + { + "services": { + "nginx": { + "build": {"context": str(worktree_context)} + } + } + } + ), + "", + ) + + override = prepare_isolation_override( + invocation, + fake_run, + self.fixture.root / "overrides", + ) + + self.assertIsNone(override) + + def test_shared_build_preserves_stdin_compose_passthrough(self) -> None: + invocation = prepare_invocation( + ["--file", "-", "build"], + False, + self.fixture.main, + ) + + def unexpected_run(*_args, **_kwargs): + raise AssertionError("stdin Compose input must remain untouched") + + override = prepare_isolation_override( + invocation, + unexpected_run, + self.fixture.root / "overrides", + ) + + self.assertIsNone(override) + def test_isolated_start_rejects_fixed_container_names(self) -> None: (self.fixture.main / ".env").write_text("APP_ENV=test\n") store = PortStore(self.fixture.root / "state" / "ports.json", lambda _port: True) @@ -1409,7 +1518,7 @@ def test_interactive_ps_renders_colored_enriched_table(self) -> None: stderr = TerminalBuffer() commands: list[list[str]] = [] - with patch.dict(os.environ, {"COLUMNS": "160"}): + with wide_terminal(self.fixture): result = run( ["ps", "--all"], cwd=self.fixture.main, @@ -1455,7 +1564,7 @@ def test_ps_highlights_a_foreign_worktree(self) -> None: stderr = TerminalBuffer() other = self.fixture.create_repo("feature") - with patch.dict(os.environ, {"COLUMNS": "160"}): + with wide_terminal(self.fixture): result = run( ["ps"], cwd=self.fixture.main, @@ -1474,7 +1583,7 @@ def test_isolated_ps_uses_the_same_columns(self) -> None: stdout = TerminalBuffer() stderr = TerminalBuffer() - with patch.dict(os.environ, {"COLUMNS": "160"}): + with wide_terminal(self.fixture): result = run( ["--isolated", "ps"], cwd=self.fixture.main, @@ -1497,7 +1606,7 @@ def test_ps_marks_containers_without_a_recorded_worktree(self) -> None: stderr = TerminalBuffer() commands: list[list[str]] = [] - with patch.dict(os.environ, {"COLUMNS": "160"}): + with wide_terminal(self.fixture): result = run( ["ps"], cwd=self.fixture.main, @@ -1522,7 +1631,7 @@ def fake_run(command: list[str], **kwargs): return subprocess.CompletedProcess(command, 0, "1a2b3c4\n", "") return self._fake_run(branch="HEAD")(command, **kwargs) - with patch.dict(os.environ, {"COLUMNS": "160"}): + with wide_terminal(self.fixture): result = run( ["ps"], cwd=self.fixture.main, @@ -1671,7 +1780,7 @@ def test_stacks_lists_shared_and_isolated_containers_across_worktrees(self) -> N commands: list[list[str]] = [] feature = self.fixture.create_repo("feature") - with patch.dict(os.environ, {"COLUMNS": "160"}): + with wide_terminal(self.fixture): result = run( ["stacks"], cwd=self.fixture.main, @@ -1706,6 +1815,49 @@ def test_stacks_lists_shared_and_isolated_containers_across_worktrees(self) -> N ], ) + def test_stacks_numbers_the_shared_stack_1_and_isolated_stacks_from_2(self) -> None: + stdout = TerminalBuffer() + feature = self.fixture.create_repo("feature") + + with wide_terminal(self.fixture): + result = run( + ["stacks"], + cwd=self.fixture.main, + stdout=stdout, + stderr=TerminalBuffer(), + run_process=self._stacks_run(feature), + now_fn=lambda: self.now, + ) + + rendered = ANSI_ESCAPE.sub("", stdout.getvalue()) + rows = [line for line in rendered.splitlines() if "example-" in line] + self.assertEqual(result, 0) + self.assertIn("ID", rendered) + self.assertIn("1.1", rows[0]) + self.assertIn("2.1", rows[1]) + # The ID is recorded, so it survives into the next listing. + self.assertEqual( + self.store.list_stack_ids(load_config(self.fixture.workspace / ".wco.toml")), + {feature.resolve(): 2}, + ) + + def test_ps_numbers_containers_within_the_current_stack(self) -> None: + stdout = TerminalBuffer() + + with wide_terminal(self.fixture): + result = run( + ["ps"], + cwd=self.fixture.main, + stdout=stdout, + stderr=TerminalBuffer(), + run_process=self._fake_run(), + now_fn=lambda: self.now, + ) + + rendered = ANSI_ESCAPE.sub("", stdout.getvalue()) + self.assertEqual(result, 0) + self.assertEqual(self._column(rendered, "ID"), "1.1") + def test_stacks_all_includes_stopped_containers(self) -> None: commands: list[list[str]] = [] feature = self.fixture.create_repo("feature") @@ -1746,6 +1898,10 @@ def test_stacks_json_reports_every_container(self) -> None: ("isolated", str(feature.resolve()), "feature/login"), ], ) + self.assertEqual( + [(item["id"], item["stack_id"]) for item in payload["containers"]], + [("1.1", 1), ("2.1", 2)], + ) def test_stacks_reports_an_empty_workspace(self) -> None: stdout = TerminalBuffer() @@ -1910,5 +2066,295 @@ def test_no_color_environment_disables_ansi(self) -> None: self.assertNotRegex(stdout.getvalue(), r"\x1b\[(?:3[0-9]|9[0-7])m") +class StackIdTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = WorkspaceFixture() + self.config = load_config(self.fixture.workspace / ".wco.toml") + self.store = PortStore( + self.fixture.root / "state" / "ports.json", + availability=lambda _port: True, + ) + self.store.path.parent.mkdir(parents=True, exist_ok=True) + + def tearDown(self) -> None: + self.fixture.close() + + def test_ids_start_after_the_shared_stack_and_are_stable(self) -> None: + feature = self.fixture.create_repo("feature") + first = self.store.stack_id(self.config, self.fixture.main) + second = self.store.stack_id(self.config, feature) + + self.assertEqual(first, SHARED_STACK_ID + 1) + self.assertEqual(second, SHARED_STACK_ID + 2) + self.assertEqual(self.store.stack_id(self.config, self.fixture.main), first) + self.assertEqual( + self.store.worktree_for_stack_id(self.config, second), feature + ) + self.assertIsNone(self.store.worktree_for_stack_id(self.config, 99)) + + def test_a_removed_worktree_frees_its_id_for_reuse(self) -> None: + feature = self.fixture.create_repo("feature") + self.store.stack_id(self.config, self.fixture.main) + self.assertEqual(self.store.stack_id(self.config, feature), 3) + + subprocess.run(["rm", "-rf", str(feature)], check=True) + self.assertEqual(self.store.list_stack_ids(self.config), {self.fixture.main: 2}) + + other = self.fixture.create_repo("other") + self.assertEqual(self.store.stack_id(self.config, other), 3) + + def test_ids_are_allocated_without_any_isolated_ports(self) -> None: + (self.fixture.workspace / ".wco.toml").write_text( + CONFIG.split("[isolation.ports]")[0] + ) + config = load_config(self.fixture.workspace / ".wco.toml") + self.assertEqual(config.isolation.ports, {}) + + with patch("wco.cli.default_port_store", return_value=self.store): + invocation = prepare_invocation(["ps"], True, self.fixture.main) + + self.assertEqual(invocation.stack_id, SHARED_STACK_ID + 1) + self.assertIsNone(invocation.slot) + + def test_a_version_1_state_file_is_upgraded_without_losing_assignments(self) -> None: + slot, ports = 1, {"HTTP_PORT": 8100, "DEV_PORT": 5100} + self.store.path.write_text( + json.dumps( + { + "version": 1, + "assignments": [ + { + "config": str(self.config.path), + "worktree": str(self.fixture.main), + "slot": slot, + "ports": ports, + } + ], + } + ) + ) + + identifier = self.store.stack_id(self.config, self.fixture.main) + data = json.loads(self.store.path.read_text()) + + self.assertEqual(data["version"], STATE_VERSION) + self.assertEqual(len(data["assignments"]), 1) + self.assertEqual(data["assignments"][0]["slot"], slot) + self.assertEqual(data["assignments"][0]["ports"], ports) + self.assertEqual(data["stacks"][0]["id"], identifier) + + def test_an_unknown_state_version_is_still_rejected(self) -> None: + self.store.path.write_text(json.dumps({"version": 99, "assignments": []})) + with self.assertRaises(WcoError): + self.store.stack_id(self.config, self.fixture.main) + + +class TargetParsingTests(unittest.TestCase): + def test_an_id_is_only_read_directly_after_the_compose_command(self) -> None: + self.assertEqual(split_target(["down", "2"]), (["down"], Target(2, None))) + self.assertEqual( + split_target(["restart", "2.1"]), (["restart"], Target(2, 1)) + ) + self.assertEqual( + split_target(["--profile", "x", "down", "3"]), + (["--profile", "x", "down"], Target(3, None)), + ) + + def test_numeric_arguments_elsewhere_are_passed_through(self) -> None: + for arguments in ( + ["logs", "--tail", "20"], + ["logs", "-f", "2.1"], + ["up", "-d"], + ["down"], + ): + self.assertEqual(split_target(arguments), (list(arguments), None)) + + def test_zero_is_rejected_as_an_id(self) -> None: + for arguments in (["down", "0"], ["restart", "2.0"]): + with self.assertRaises(WcoError) as error: + split_target(arguments) + self.assertIn("IDs start at 1", str(error.exception)) + + +class TargetDispatchTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = WorkspaceFixture() + self.feature = self.fixture.create_repo("feature") + self.config = load_config(self.fixture.workspace / ".wco.toml") + self.store = PortStore( + self.fixture.root / "state" / "ports.json", + availability=lambda _port: True, + ) + self.store.path.parent.mkdir(parents=True, exist_ok=True) + for patcher in ( + patch("wco.cli.default_port_store", return_value=self.store), + patch("wco.cli._validate_isolated_start"), + ): + patcher.start() + self.addCleanup(patcher.stop) + # Startup commands such as 'restart' require this in the target worktree. + (self.feature / ".env").write_text("") + # Give the feature worktree stack ID 2. + self.stack_id = self.store.stack_id(self.config, self.feature.resolve()) + self.project = _isolated_name("example", self.feature.resolve()) + + def tearDown(self) -> None: + self.fixture.close() + + def _capture(self, captured: dict[str, object]): + def capture(file: str, args: object, environment: object) -> None: + captured.update(file=file, args=args, environment=environment) + + return capture + + def _container_run(self, services: list[str]): + """Docker reports one container per service in the isolated project.""" + containers = [ + { + "Id": f"{index}" * 64, + "Name": f"/{self.project}-{service}-1", + "Config": { + "Labels": { + "com.docker.compose.project": self.project, + "com.docker.compose.service": service, + "com.docker.compose.container-number": "1", + } + }, + "State": {"Status": "running"}, + } + for index, service in enumerate(services, start=1) + ] + + def fake_run(command: list[str], **_kwargs): + if command[:2] == ["docker", "ps"]: + ids = "\n".join(str(item["Id"]) for item in containers) + return subprocess.CompletedProcess(command, 0, ids + "\n", "") + if command[:2] == ["docker", "inspect"]: + return subprocess.CompletedProcess( + command, 0, json.dumps(containers), "" + ) + return subprocess.CompletedProcess(command, 0, "", "") + + return fake_run + + def _run(self, argv: list[str], run_process=None, cwd: Path | None = None): + captured: dict[str, object] = {} + stdout, stderr = io.StringIO(), io.StringIO() + result = run( + argv, + cwd=cwd or self.fixture.main, + stdout=stdout, + stderr=stderr, + exec_fn=self._capture(captured), + run_process=run_process + or (lambda command, **_k: subprocess.CompletedProcess(command, 0, "", "")), + ) + return result, captured, stdout.getvalue(), stderr.getvalue() + + def test_a_stack_id_retargets_another_worktree(self) -> None: + result, captured, _stdout, stderr = self._run(["down", "2"]) + + command = list(captured["args"]) + self.assertEqual(result, 0) + self.assertEqual( + command[command.index("--project-name") + 1], self.project + ) + self.assertEqual( + command[command.index("--project-directory") + 1], + str(self.feature.resolve()), + ) + self.assertEqual(command[-1], "down") + self.assertIn("isolated", stderr) + self.assertIn("stack 2", ANSI_ESCAPE.sub("", stderr)) + + def test_isolated_flag_is_accepted_alongside_an_isolated_id(self) -> None: + _result, with_flag, _out, _err = self._run(["--isolated", "down", "2"]) + _result, without_flag, _out, _err = self._run(["down", "2"]) + + self.assertEqual(list(with_flag["args"]), list(without_flag["args"])) + + def test_stack_1_is_the_shared_stack_and_rejects_isolated(self) -> None: + result, captured, _stdout, _stderr = self._run(["down", "1"]) + command = list(captured["args"]) + self.assertEqual(result, 0) + self.assertEqual(command[command.index("--project-name") + 1], "example") + + result, _captured, _stdout, stderr = self._run(["--isolated", "down", "1"]) + self.assertEqual(result, 1) + self.assertIn("shared stack", stderr) + + def test_a_container_id_resolves_to_its_service_name(self) -> None: + result, captured, _stdout, _stderr = self._run( + ["restart", "2.2"], run_process=self._container_run(["db", "web"]) + ) + + command = list(captured["args"]) + self.assertEqual(result, 0) + self.assertEqual(command[-2:], ["restart", "web"]) + self.assertEqual( + command[command.index("--project-name") + 1], self.project + ) + + def test_a_container_id_precedes_the_command_arguments_for_exec(self) -> None: + result, captured, _stdout, _stderr = self._run( + ["exec", "2.1", "sh"], run_process=self._container_run(["db", "web"]) + ) + + self.assertEqual(result, 0) + self.assertEqual(list(captured["args"])[-3:], ["exec", "db", "sh"]) + + def test_down_rejects_a_container_id(self) -> None: + result, _captured, _stdout, stderr = self._run(["down", "2.1"]) + + self.assertEqual(result, 1) + self.assertIn("targets a whole stack", stderr) + self.assertIn("wco stop 2.1", stderr) + + def test_an_out_of_range_container_id_is_rejected(self) -> None: + result, _captured, _stdout, stderr = self._run( + ["restart", "2.9"], run_process=self._container_run(["db", "web"]) + ) + + self.assertEqual(result, 1) + self.assertIn("out of range", stderr) + self.assertIn("2.1-2.2", stderr) + + def test_an_unknown_stack_id_names_wco_stacks(self) -> None: + result, _captured, _stdout, stderr = self._run(["down", "7"]) + + self.assertEqual(result, 1) + self.assertIn("No stack has ID 7", stderr) + self.assertIn("wco stacks", stderr) + + def test_a_stack_id_whose_worktree_is_gone_is_reported(self) -> None: + data = json.loads(self.store.path.read_text()) + data["stacks"][0]["worktree"] = str(self.fixture.workspace / "vanished") + self.store.path.write_text(json.dumps(data)) + + result, _captured, _stdout, stderr = self._run(["down", "2"]) + + self.assertEqual(result, 1) + # _prune drops the entry before lookup, so the ID reads as unknown. + self.assertIn("No stack has ID 2", stderr) + + def test_down_all_is_unaffected_by_target_parsing(self) -> None: + commands: list[list[str]] = [] + + def fake_run(command: list[str], **_kwargs): + commands.append(command) + return subprocess.CompletedProcess(command, 0, "", "") + + result = run( + ["--isolated", "down", "--all"], + cwd=self.fixture.main, + stdout=io.StringIO(), + stderr=io.StringIO(), + run_process=fake_run, + ) + + self.assertEqual(result, 0) + self.assertTrue(any(command[-1] == "down" for command in commands)) + + if __name__ == "__main__": unittest.main()