diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0c43e..5441a79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added +- **Herdr plugin** (`integrations/herdr/`) — a thin herdr plugin that runs `git workspace prepare ` whenever herdr creates or opens a worktree (`worktree.created` / `worktree.opened` events), so worktrees born on the herdr side get their assets and setup hooks no matter who initiated them. Also registers `prepare` / `reprepare` plugin actions for the current worktree. Non-git-workspace repositories are skipped silently; failures raise a herdr notification with the retry command. Install with `herdr plugin link /integrations/herdr`. +- Lock contention now exits with code 75 (`EX_TEMPFAIL`) instead of 1, so external hosts can distinguish "another operation is already running — retry later" from real failures. The herdr plugin uses this to skip benignly when `up --backend herdr` is still preparing the worktree it just created. +- **Herdr backend** — `git workspace up` can now create worktrees through [herdr](https://herdr.dev) and present them as herdr workspaces. Inside a verified herdr session (`HERDR_ENV=1` with a reachable session socket), the backend is auto-detected: plain `up` creates the worktree via herdr, prepares it, opens it as a herdr workspace, and focuses it (unless `--no-focus` or `--detached`). `down` closes the workspace (worktree preserved); `rm` removes the worktree through herdr (branch preserved). Outside herdr — or with `--backend native` / `[workspace] backend = "native"` in the manifest — behavior is unchanged. +- Backend selection surface: `up --backend `, `--provider `, `--presenter `, `--focus/--no-focus`, plus a `[workspace]` manifest table (`backend`, `provider`, `presenter`). Explicit provider/presenter values override the backend preset; a CLI `--backend` overrides manifest settings. The herdr executable resolves via explicit config → `HERDR_BIN_PATH` → `PATH`. +- Reusable presenter contract tests (`tests/contracts/presenter.py`); herdr provider and presenter both pass their contracts against a fake herdr executable, so CI never needs herdr installed. +- `prepare` (and the `reset` alias) always import worktrees through native git, so preparation keeps working in CI and external hosts regardless of the configured backend. +- `git workspace prepare [PATH]` — prepares an existing worktree (assets + `on_setup` hooks) regardless of which tool created it, including worktrees created directly with `git worktree add` at arbitrary paths outside the workspace root. The owning workspace is discovered through the worktree's git metadata (`.workspace` lives as a sibling of the main git directory). No-ops when the worktree is already prepared; `--force` re-runs setup. This is the primitive external hosts (editor plugins, CI, other tools) should invoke. +- Per-worktree lifecycle state under `/.workspace/.state/` (`created` → `preparing` → `ready`/`preparation-failed`, plus `detached`/`tearing-down`). A failed `on_setup` is now remembered: the next `up` or `prepare` retries preparation instead of silently treating the worktree as healthy. Worktrees without a state file (created by older versions) remain fully supported. +- Per-worktree operation locks: concurrent mutating operations (`up`, `prepare`, `down`, `rm`, `prune --apply`) on the same worktree now fail fast with a clear message instead of racing. +- `doctor` now flags worktrees whose preparation failed (with the retry command) and stale state files whose worktree no longer exists (auto-fixable). +- Internal provider/presenter architecture: worktree lifecycle is owned by a `WorktreeProvider` (native git today), presentation by an optional `WorkspacePresenter` (none today), orchestrated by a `WorkspaceService`. This is the seam future backends (editor/multiplexer integrations, alternative worktree managers) plug into; no user-facing backend selection exists yet. + +### Changed +- **Breaking:** `git workspace prune --apply` now runs the full removal lifecycle per worktree — `on_detach` and `on_teardown` hooks execute before deletion (previously hooks were skipped). A failing worktree is skipped and pruning continues; failures are summarized and the command exits non-zero. +- **Breaking:** the CLI now exits with a non-zero status code when a command fails with a git-workspace error (previously it printed the error but exited 0). +- Failed preparation on `up` now leaves the worktree in place, records the failure, and prints a retry command (`git workspace prepare `); it never cleans up automatically. + +### Deprecated +- `git workspace reset` — use `git workspace prepare --force` instead. `reset` currently remains as an alias with identical behavior. + ## [0.8.0] - 2026-05-04 ### Added diff --git a/README.md b/README.md index defe15c..82e5988 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,15 @@ With `git-workspace`, each branch lives in its own directory. You `up` into it, - [How it works](#how-it-works) - [Installation](#installation) - [Quick start](#quick-start) +- [Backends](#backends) - [Commands](#commands) - [Workspace manifest](#workspace-manifest) - [Lifecycle hooks](#lifecycle-hooks) - [Fingerprints](#fingerprints) - [Assets: links and copies](#assets-links-and-copies) +- [Preparing worktrees](#preparing-worktrees) - [Pruning stale worktrees](#pruning-stale-worktrees) +- [Shared services via docker compose](#shared-services-via-docker-compose) - [Detached mode](#detached-mode) - [Diagnosing a workspace](#diagnosing-a-workspace) - [Debugging](#debugging) @@ -50,6 +53,9 @@ With `git-workspace`, each branch lives in its own directory. You `up` into it, - 🌳 **Worktree-per-branch** — every branch gets its own directory; no more dirty working trees - 🪝 **Lifecycle hooks** — run scripts on setup, attach, detach, and teardown +- 🔌 **Pluggable backends** — create and present worktrees through [herdr](https://herdr.dev), auto-detected inside a herdr session +- 🛠️ **Prepare anything** — `git workspace prepare` sets up worktrees created by any tool, at any path +- 🧾 **Lifecycle state** — failed setups are remembered and retried instead of silently ignored - 🔗 **Symlink injection** — link dotfiles and config from a shared config repo into every worktree - 📋 **File copying** — copy mutable config files that each worktree can edit independently - 🔒 **Override assets** — replace tracked files with symlinks or copies without touching git history @@ -138,6 +144,49 @@ You're now inside `my-project/main/` — a real git worktree on the `main` branc --- +## Backends + +Worktree creation and presentation are pluggable. Two backends exist today: + +| Backend | Worktrees created by | Presented as | +|---|---|---| +| `native` | git | — (nothing opens) | +| `herdr` | [herdr](https://herdr.dev) | a herdr workspace (opened and focused) | + +Selection is automatic: inside a verified herdr session (`HERDR_ENV` set and the +session socket reachable), `up` uses the herdr backend — the worktree is created +through herdr, prepared, and opened as a workspace. Everywhere else the native +backend is used. `down` closes the herdr workspace (keeping the worktree); +`rm` removes the worktree through herdr (keeping the branch). + +Override per invocation or per repository: + +```bash +git workspace up feature/auth --backend native # opt out for one call +git workspace up feature/auth --no-focus # open but don't switch to it +git workspace up feature/auth \ + --provider native-git --presenter herdr # explicit composition +``` + +```toml +# .workspace/manifest.toml +[workspace] +backend = "native" # or "herdr" / "auto" (default) +``` + +`git workspace prepare` always works through plain git, regardless of backend — +it is the primitive external tools and CI invoke. + +For the reverse direction — worktrees created from herdr's own UI — a thin +[herdr plugin](integrations/herdr/) runs `git workspace prepare` automatically +on herdr's worktree events: + +```bash +herdr plugin link /path/to/git-workspace/integrations/herdr +``` + +--- + ## Commands > [!TIP] @@ -151,7 +200,8 @@ You're now inside `my-project/main/` — a real git worktree on the `main` branc | `git workspace clone` | Clone an existing repository into workspace format | | `git workspace up` | Open a worktree, creating it if it doesn't exist | | `git workspace down` | Deactivate a worktree and run teardown hooks | -| `git workspace reset` | Reapply copies, links, and re-run setup hooks | +| `git workspace prepare` | Prepare an existing worktree (assets + setup hooks), wherever it was created | +| `git workspace reset` | Deprecated alias for `prepare --force` | | `git workspace rm` | Remove a worktree (branch is preserved) | | `git workspace ls` | List all active worktrees with branch, path, and age | | `git workspace prune` | Remove stale worktrees by age (dry-run by default) | @@ -215,10 +265,10 @@ Hooks come in two pairs that map to the two lifetimes a worktree has: | Event | When it runs | |---|---| - | `on_setup` | After a worktree is first created, or on `reset` | + | `on_setup` | When a worktree is prepared: on `up` for a new worktree, on `prepare` for an unprepared one, and on `prepare --force` | | `on_attach` | On `up` in interactive mode (skipped with `--detached`) | - | `on_detach` | On `down` and at the start of `rm` | - | `on_teardown` | On `rm`, after `on_detach`, before the directory is deleted | + | `on_detach` | On `down`, and at the start of `rm` and `prune --apply` | + | `on_teardown` | On `rm` and `prune --apply`, after `on_detach`, before the directory is deleted |
@@ -285,7 +335,7 @@ commands = ["start_long_running_task"] #### Impersonating a branch with `--as` -All hook-running commands (`up`, `down`, `reset`, `rm`) accept `-a`/`--as ` to override which branch is used when evaluating hook conditions. The real `GIT_WORKSPACE_BRANCH` environment variable and git state are **not** affected. +All hook-running commands (`up`, `down`, `prepare`, `rm`) accept `-a`/`--as ` to override which branch is used when evaluating hook conditions. The real `GIT_WORKSPACE_BRANCH` environment variable and git state are **not** affected. ```bash # Run hooks as if this were a gabriel/* branch, even though the real branch is feat/my-feature @@ -318,7 +368,7 @@ length = 12 # optional; default: 12 Each fingerprint is exposed as `GIT_WORKSPACE_FINGERPRINT_` (same normalization as vars — uppercase, non-alphanumeric replaced by `_`). The above example produces `GIT_WORKSPACE_FINGERPRINT_DOCKER_DEPS`. -Fingerprints are recomputed on every `up`, `reset`, `down`, and `rm` invocation. Files are looked up relative to the worktree root; a missing or unreadable file contributes its path and the literal marker `NULL` to the hash rather than failing. +Fingerprints are recomputed on every `up`, `prepare`, `down`, and `rm` invocation. Files are looked up relative to the worktree root; a missing or unreadable file contributes its path and the literal marker `NULL` to the hash rather than failing.
Usage example @@ -343,7 +393,7 @@ fi ## Assets: links and copies -Assets let you inject shared files — dotfiles, editor configs, secrets — into every worktree from your config repository. They live in `.workspace/assets/` and are applied automatically on `up` and `reset`. +Assets let you inject shared files — dotfiles, editor configs, secrets — into every worktree from your config repository. They live in `.workspace/assets/` and are applied automatically whenever a worktree is prepared (`up` on a new worktree, `prepare`, `prepare --force`). ### Links @@ -357,7 +407,7 @@ target = ".env.local" ### Copies -File copies from `.workspace/assets` into the worktree. Each worktree gets its own independent file. Copies are idempotent — `reset` overwrites them with a fresh copy from the source. +File copies from `.workspace/assets` into the worktree. Each worktree gets its own independent file. Copies are idempotent — `prepare --force` overwrites them with a fresh copy from the source. ```toml [[copy]] @@ -369,7 +419,7 @@ target = "config.local.yaml" Overwrite control
-Set `overwrite = false` to seed the file once and preserve local edits across resets. The file is still created on the first `up`, but subsequent `reset` calls leave it untouched. +Set `overwrite = false` to seed the file once and preserve local edits. The file is still created on the first `up`, but subsequent `prepare --force` calls leave it untouched. ```toml [[copy]] @@ -436,6 +486,26 @@ override = true --- +## Preparing worktrees + +*Preparation* is the setup half of the lifecycle: applying assets and running `on_setup` hooks. It normally happens automatically when `up` creates a worktree, but it is also available as a standalone command: + +```bash +git workspace prepare # prepare the worktree you're standing in +git workspace prepare # prepare a specific worktree +git workspace prepare --force # re-run preparation even if already prepared +``` + +`prepare` works on **any** git worktree of the workspace's repository — including ones created directly with `git worktree add`, by herdr, or by any other tool, at any path on disk. The owning workspace is discovered through the worktree's git metadata, so you don't need to be inside the workspace root. This makes it the primitive external integrations (like the [herdr plugin](integrations/herdr/)) and CI invoke. + +Preparation is tracked per worktree under `.workspace/.state/`: + +- An already-prepared worktree is a no-op (`prepare` reports it and exits 0); pass `--force` to re-apply assets and re-run `on_setup` — this is the repair/refresh path (the old `reset` command is now a deprecated alias for it). +- A **failed** preparation is remembered: the worktree is preserved, the error is recorded, and the next `up` or `prepare` retries setup instead of pretending the worktree is healthy. The failure and its retry command also show up in `git workspace doctor`. +- Mutating commands hold a per-worktree lock; a concurrent operation fails fast with exit code `75` (`EX_TEMPFAIL`) so scripts can distinguish "busy, retry later" from a real failure (exit `1`). + +--- + ## Pruning stale worktrees Over time, worktrees accumulate. The `prune` command removes the ones you're no longer using: @@ -448,7 +518,7 @@ git workspace prune --older-than-days 14 git workspace prune --older-than-days 14 --apply ``` -Pruning force-removes worktrees directly and does **not** run lifecycle hooks. Configure defaults in the manifest so you can just run `git workspace prune`: +Each pruned worktree goes through the full removal lifecycle — `on_detach` and `on_teardown` hooks run before deletion, so services and state get cleaned up just like with `rm`. A worktree whose hooks fail is skipped and reported at the end (exit code 1), without stopping the rest of the prune. Configure defaults in the manifest so you can just run `git workspace prune`: ```toml [prune] @@ -503,7 +573,7 @@ For CI pipelines, automation, or agent workflows where you don't want interactiv git workspace up main --detached ``` -This runs `on_setup` (on first creation only) but skips `on_attach`. Combine with `-o` for fully machine-readable output: +This runs `on_setup` (on first creation only) but skips `on_attach`. On a backend with a presenter (e.g. herdr), detached mode also opens the presentation without focusing it — your current view stays put. Combine with `-o` for fully machine-readable output: ```bash WORKTREE=$(git workspace up main --detached -o) @@ -533,7 +603,7 @@ Otherwise it lists findings by severity: ⚠ base_branch 'develop' does not resolve to any local or remote ref ``` -**Errors** (✗) indicate problems that will break `up`, `reset`, or hooks. **Warnings** (⚠) indicate configuration that is suspicious but may be intentional — for example, a hook entry that looks like a bin script name but has no matching file (it may be an ad-hoc inline command). +**Errors** (✗) indicate problems that will break `up`, `prepare`, or hooks. **Warnings** (⚠) indicate configuration that is suspicious but may be intentional — for example, a hook entry that looks like a bin script name but has no matching file (it may be an ad-hoc inline command). The command exits 1 if any errors are found, 0 if the workspace is clean or has warnings only. @@ -597,6 +667,8 @@ git workspace doctor --fix --yes | warning | Unknown copy placeholder | A `{{ GIT_WORKSPACE_* }}` placeholder in a copy asset is not a base variable, manifest var, or fingerprint | — | | warning | Unknown base branch | `base_branch` does not resolve to any local or remote ref | — | | warning | Stale worktree | A git-registered worktree's directory no longer exists on disk | auto | +| warning | Failed preparation | A worktree's last preparation failed; reports the error and the retry command | — | +| warning | Stale workspace state | A state file exists for a worktree that no longer exists on disk | auto | | warning | Fingerprint/var name overlap | A `[[fingerprint]]` name and a `[vars]` key normalize the same (different env prefixes, but may be confusing in templates) | — | | warning | Empty fingerprint files list | A `[[fingerprint]]` has no entries in `files` | — | | warning | Duplicate fingerprint file | The same file path appears more than once within one `[[fingerprint]]` | auto | @@ -646,7 +718,7 @@ The test suite includes both unit tests and integration tests. Integration tests ```bash uv run ruff check src/ tests/ uv run ruff format --check src/ tests/ -uv run ty check src/ +uv run ty check src/ tests/ ```
@@ -655,23 +727,39 @@ uv run ty check src/ Project layout
+The architecture separates three concerns: worktree lifecycle (**providers**), project setup (**the preparer**), and how a worktree is surfaced to you (**presenters**). A **backend** is one provider plus an optional presenter, orchestrated by the workspace service. + ``` src/git_workspace/ -├── cli/commands/ ← one file per command -├── assets.py ← symlink and copy management -├── doctor.py ← workspace diagnostic checks -├── env.py ← GIT_WORKSPACE_* environment variable construction -├── errors.py ← exception hierarchy -├── fingerprint.py ← worktree file hashing and fingerprint env var computation -├── git.py ← subprocess wrappers for git -├── hooks.py ← hook logic and execution -├── manifest.py ← manifest parsing -├── operations.py ← lifecycle orchestration -├── ui.py ← ui-related logic -├── utils.py ← general logic that doesn't fit elsewhere -├── workspace.py ← top-level workspace model -└── worktree.py ← worktree model -``` +├── cli/commands/ ← one file per command (thin wrappers over the service) +├── backends/ ← backend presets and resolution (native/herdr/auto) +├── providers/ ← WorktreeProvider protocol + native-git and herdr providers +├── presenters/ ← WorkspacePresenter protocol + herdr presenter +├── subprocesses/ ← injectable CommandRunner + git and herdr CLI plumbing +├── workspace/ +│ ├── core.py ← workspace model, paths, discovery/resolution +│ ├── worktree.py ← worktree model +│ ├── models.py ← domain models and lifecycle states +│ ├── service.py ← lifecycle orchestration (up/prepare/down/rm/prune) +│ ├── preparer.py ← backend-agnostic setup: assets, env, hooks +│ ├── state.py ← per-worktree lifecycle state persistence +│ ├── lock.py ← per-worktree operation locks +│ ├── assets.py ← symlink and copy management +│ ├── hooks.py ← hook logic and execution +│ ├── env.py ← GIT_WORKSPACE_* environment variable construction +│ └── fingerprint.py ← worktree file hashing +├── doctor.py ← workspace diagnostic checks +├── manifest.py ← manifest parsing +├── errors.py ← exception hierarchy +├── cache.py ← file cache for hook scripts +├── ui.py ← ui-related logic +└── utils.py ← general logic that doesn't fit elsewhere + +integrations/herdr/ ← thin herdr plugin (runs `prepare` on herdr worktree events) +tests/contracts/ ← reusable provider/presenter contract tests +``` + +New providers must pass `tests/contracts/provider.py`; new presenters, `tests/contracts/presenter.py`. External tools (herdr, and fakes in tests) are exercised through the injectable `CommandRunner` — integration tests never require herdr installed. diff --git a/integrations/herdr/README.md b/integrations/herdr/README.md new file mode 100644 index 0000000..a430a2f --- /dev/null +++ b/integrations/herdr/README.md @@ -0,0 +1,46 @@ +# git-workspace herdr plugin + +Prepares git-workspace worktrees automatically when [herdr](https://herdr.dev) +creates or opens them: on `worktree.created` / `worktree.opened` the plugin +runs `git workspace prepare `, so copies, links, and `on_setup` hooks +are applied no matter which side initiated the worktree. + +It also registers two actions: + +| Action | Runs | +|---|---| +| **Prepare worktree** | `git workspace prepare` for the current worktree | +| **Re-run worktree preparation** | `git workspace prepare --force` | + +## Install + +```bash +herdr plugin link /path/to/git-workspace/integrations/herdr +``` + +or straight from GitHub: + +```bash +herdr plugin install ewilazarus/git-workspace/integrations/herdr +``` + +Requires `git-workspace` on `PATH` (or `GIT_WORKSPACE_BIN` pointing at it) +and herdr ≥ 0.7.0. + +## Behavior + +- Repositories without a `.workspace/manifest.toml` are skipped silently — + herdr fires worktree events for every repo it manages. +- Already-prepared worktrees are a no-op (`prepare` checks lifecycle state). +- When `git workspace up --backend herdr` created the worktree itself, the + plugin detects the in-flight preparation (exit code 75, lock contention) + and skips — the creator finishes the job. +- Failures surface as a herdr notification with the retry command; full + output is in `herdr plugin log list --plugin git-workspace`. + +## Design + +The plugin is deliberately thin: it only resolves the worktree path from the +event payload and shells out to `git workspace prepare`. It parses no project +configuration, applies no assets, runs no hooks directly, and keeps no state — +all lifecycle rules live in git-workspace itself. diff --git a/integrations/herdr/herdr-plugin.toml b/integrations/herdr/herdr-plugin.toml new file mode 100644 index 0000000..b6aaa75 --- /dev/null +++ b/integrations/herdr/herdr-plugin.toml @@ -0,0 +1,26 @@ +id = "git-workspace" +name = "git-workspace" +version = "0.1.0" +min_herdr_version = "0.7.0" +platforms = ["macos", "linux"] +description = "Prepares git-workspace worktrees when herdr creates or opens them" + +[[events]] +on = "worktree.created" +command = ["hooks/prepare"] + +[[events]] +on = "worktree.opened" +command = ["hooks/prepare"] + +[[actions]] +id = "prepare" +title = "Prepare worktree" +description = "Run `git workspace prepare` for the current worktree" +command = ["hooks/prepare"] + +[[actions]] +id = "reprepare" +title = "Re-run worktree preparation" +description = "Run `git workspace prepare --force` for the current worktree" +command = ["hooks/prepare", "--force"] diff --git a/integrations/herdr/hooks/prepare b/integrations/herdr/hooks/prepare new file mode 100755 index 0000000..2515522 --- /dev/null +++ b/integrations/herdr/hooks/prepare @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +Thin herdr plugin hook: prepares a git-workspace worktree. + +Invoked by herdr on worktree.created / worktree.opened events (payload in +HERDR_PLUGIN_EVENT_JSON) and by the plugin's prepare/reprepare actions +(context in HERDR_PLUGIN_CONTEXT_JSON). + +Deliberately thin, per the git-workspace backend architecture: it only +resolves the worktree path and shells out to `git workspace prepare`. It +never parses project configuration, applies assets, runs hooks directly, +or keeps state of its own — all lifecycle rules live in git-workspace. +""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +# git-workspace exits with EX_TEMPFAIL when another operation already holds +# the worktree's lifecycle lock — e.g. `git workspace up --backend herdr` is +# still preparing the worktree it just created through herdr. That is not a +# failure; the creator finishes the preparation. +GIT_WORKSPACE_LOCKED_EXIT_CODE = 75 + + +def resolve_git_workspace(): + explicit = os.environ.get("GIT_WORKSPACE_BIN") + if explicit: + return explicit + return shutil.which("git-workspace") + + +def notify(body): + herdr_bin = os.environ.get("HERDR_BIN_PATH") or shutil.which("herdr") + if herdr_bin is None: + return + subprocess.run( + [herdr_bin, "notification", "show", "git-workspace", "--body", body], + capture_output=True, + ) + + +def resolve_worktree(): + """Returns (worktree_path, repo_root) from the event or action context.""" + raw_event = os.environ.get("HERDR_PLUGIN_EVENT_JSON") + if raw_event: + data = json.loads(raw_event).get("data", {}) + workspace_worktree = data.get("workspace", {}).get("worktree", {}) + path = data.get("worktree", {}).get("path") or workspace_worktree.get("checkout_path") + return path, workspace_worktree.get("repo_root") + + raw_context = os.environ.get("HERDR_PLUGIN_CONTEXT_JSON") + if raw_context: + context = json.loads(raw_context) + worktree = context.get("worktree") or {} + path = ( + worktree.get("checkout_path") + or context.get("workspace_cwd") + or context.get("focused_pane_cwd") + ) + return path, worktree.get("repo_root") + + return None, None + + +def main(): + force = "--force" in sys.argv[1:] + + path, repo_root = resolve_worktree() + if not path: + print("no worktree path in the herdr event or context; nothing to prepare") + return 0 + + # Only act on git-workspace repositories; herdr fires these events for + # every repo it manages. + if repo_root and not (Path(repo_root) / ".workspace" / "manifest.toml").is_file(): + print(f"{repo_root} is not a git-workspace workspace; skipping") + return 0 + + git_workspace = resolve_git_workspace() + if git_workspace is None: + print( + "git-workspace executable not found (install it or set GIT_WORKSPACE_BIN)", + file=sys.stderr, + ) + notify(f"git-workspace is not installed; cannot prepare {Path(path).name}") + return 1 + + cmd = [git_workspace, "prepare", path] + if force: + cmd.append("--force") + + print("running:", " ".join(cmd), flush=True) + result = subprocess.run(cmd) + + if result.returncode == GIT_WORKSPACE_LOCKED_EXIT_CODE: + print("another git-workspace operation is already preparing this worktree; skipping") + return 0 + if result.returncode != 0: + notify( + f"Preparation failed for {Path(path).name} — " + f"retry with: git workspace prepare --force {path}" + ) + return result.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/git_workspace/__init__.py b/src/git_workspace/__init__.py index a2609c5..da2c2c9 100644 --- a/src/git_workspace/__init__.py +++ b/src/git_workspace/__init__.py @@ -3,9 +3,14 @@ import sys from git_workspace import cli -from git_workspace.errors import GitWorkspaceError +from git_workspace.errors import GitWorkspaceError, WorkspaceLockedError from git_workspace.ui import console +# Exit code for lock contention (EX_TEMPFAIL): the operation did not run +# because another one holds the worktree's lifecycle lock. External hosts +# (e.g. the herdr plugin) treat this as "retry later", not as a failure. +EXIT_CODE_LOCKED = 75 + LOG_LEVEL = getattr( logging, os.environ.get("GIT_WORKSPACE_LOG_LEVEL", "").upper(), logging.CRITICAL ) @@ -22,6 +27,11 @@ def main() -> None: try: cli.app() + except WorkspaceLockedError as e: + console.error(str(e)) + logger.exception("Failed to run command") + sys.exit(EXIT_CODE_LOCKED) except GitWorkspaceError as e: console.error(str(e)) logger.exception("Failed to run command") + sys.exit(1) diff --git a/src/git_workspace/backends/__init__.py b/src/git_workspace/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/git_workspace/backends/errors.py b/src/git_workspace/backends/errors.py new file mode 100644 index 0000000..c45269a --- /dev/null +++ b/src/git_workspace/backends/errors.py @@ -0,0 +1,27 @@ +from git_workspace.errors import ( + ExternalCommandError, + PresentationNotFoundError, + PresenterCapabilityError, + PresenterError, + PresenterUnavailableError, + ProviderError, + ProviderUnavailableError, + UnsafeWorktreeRemovalError, + WorkspaceBackendError, + WorktreeAlreadyExistsError, + WorktreeNotFoundError, +) + +__all__ = [ + "ExternalCommandError", + "PresentationNotFoundError", + "PresenterCapabilityError", + "PresenterError", + "PresenterUnavailableError", + "ProviderError", + "ProviderUnavailableError", + "UnsafeWorktreeRemovalError", + "WorkspaceBackendError", + "WorktreeAlreadyExistsError", + "WorktreeNotFoundError", +] diff --git a/src/git_workspace/backends/models.py b/src/git_workspace/backends/models.py new file mode 100644 index 0000000..bf10f2f --- /dev/null +++ b/src/git_workspace/backends/models.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from git_workspace.presenters.base import WorkspacePresenter +from git_workspace.providers.base import WorktreeProvider + + +@dataclass(frozen=True) +class WorkspaceBackend: + """ + A configured combination of one worktree provider and zero or one + workspace presenter. A backend is configuration, not a lifecycle owner. + """ + + name: str + provider: WorktreeProvider + presenter: WorkspacePresenter | None diff --git a/src/git_workspace/backends/resolver.py b/src/git_workspace/backends/resolver.py new file mode 100644 index 0000000..403777c --- /dev/null +++ b/src/git_workspace/backends/resolver.py @@ -0,0 +1,147 @@ +import logging + +from git_workspace.backends.models import WorkspaceBackend +from git_workspace.errors import InvalidInputError, ProviderUnavailableError +from git_workspace.manifest import WorkspaceSettings +from git_workspace.presenters.base import WorkspacePresenter +from git_workspace.presenters.herdr import HerdrPresenter +from git_workspace.providers.base import WorktreeProvider +from git_workspace.providers.herdr import HerdrWorktreeProvider +from git_workspace.providers.native_git import NativeGitProvider +from git_workspace.subprocesses import herdr +from git_workspace.subprocesses.runner import DEFAULT_RUNNER, CommandRunner +from git_workspace.workspace.models import PresenterKind, ProviderKind + +logger = logging.getLogger(__name__) + +BACKEND_NATIVE = "native" +BACKEND_HERDR = "herdr" +BACKEND_AUTO = "auto" + +_KNOWN_BACKENDS = (BACKEND_NATIVE, BACKEND_HERDR, BACKEND_AUTO) + + +class WorkspaceBackendResolver: + """ + Resolves the workspace backend for an operation. + + Precedence: explicit CLI provider/presenter kinds → explicit CLI backend + name → manifest ``[workspace]`` provider/presenter → manifest backend → + verified environment detection → native. + + A CLI backend name suppresses the manifest's member overrides (the flag + expresses full intent); CLI provider/presenter kinds always win over any + preset. ``auto`` selects herdr only inside a verified herdr context — + the executable merely existing is never enough. + """ + + def __init__(self, runner: CommandRunner = DEFAULT_RUNNER) -> None: + self._runner = runner + + def resolve( + self, + *, + backend_name: str | None = None, + provider_kind: ProviderKind | None = None, + presenter_kind: PresenterKind | None = None, + settings: WorkspaceSettings | None = None, + ) -> WorkspaceBackend: + settings = settings or WorkspaceSettings() + + preset_name = backend_name or settings.backend or BACKEND_AUTO + if preset_name not in _KNOWN_BACKENDS: + raise InvalidInputError( + f"Unknown backend {preset_name!r}; available backends: {', '.join(_KNOWN_BACKENDS)}" + ) + + resolved_provider_kind = provider_kind or ( + self._parse_provider(settings.provider) if backend_name is None else None + ) + resolved_presenter_kind = presenter_kind or ( + self._parse_presenter(settings.presenter) if backend_name is None else None + ) + + name, preset_provider, preset_presenter = self._preset(preset_name) + + provider = ( + self._provider(resolved_provider_kind) + if resolved_provider_kind is not None + else preset_provider + ) + presenter = ( + self._presenter(resolved_presenter_kind) + if resolved_presenter_kind is not None + else preset_presenter + ) + + logger.debug( + "resolved backend %r (provider=%s, presenter=%s)", + name, + provider.kind, + presenter.kind if presenter else None, + ) + return WorkspaceBackend(name=name, provider=provider, presenter=presenter) + + def _preset(self, preset_name: str) -> tuple[str, WorktreeProvider, WorkspacePresenter | None]: + if preset_name == BACKEND_AUTO: + if self._herdr_context_verified(): + logger.debug("auto-detected verified herdr context") + preset_name = BACKEND_HERDR + else: + preset_name = BACKEND_NATIVE + + if preset_name == BACKEND_HERDR: + self._require_herdr() + return ( + BACKEND_HERDR, + HerdrWorktreeProvider(self._runner), + HerdrPresenter(self._runner), + ) + return (BACKEND_NATIVE, NativeGitProvider(self._runner), None) + + def _provider(self, kind: ProviderKind) -> WorktreeProvider: + if kind is ProviderKind.HERDR: + self._require_herdr() + return HerdrWorktreeProvider(self._runner) + return NativeGitProvider(self._runner) + + def _presenter(self, kind: PresenterKind) -> WorkspacePresenter | None: + if kind is PresenterKind.HERDR: + self._require_herdr() + return HerdrPresenter(self._runner) + return None + + @staticmethod + def _parse_provider(raw: str | None) -> ProviderKind | None: + if raw is None: + return None + try: + return ProviderKind(raw) + except ValueError: + raise InvalidInputError( + f"Unknown provider {raw!r}; available providers: " + f"{', '.join(kind.value for kind in ProviderKind)}" + ) from None + + @staticmethod + def _parse_presenter(raw: str | None) -> PresenterKind | None: + if raw is None: + return None + try: + return PresenterKind(raw) + except ValueError: + raise InvalidInputError( + f"Unknown presenter {raw!r}; available presenters: " + f"{', '.join(kind.value for kind in PresenterKind)}" + ) from None + + @staticmethod + def _herdr_context_verified() -> bool: + return herdr.in_verified_context() and herdr.resolve_executable() is not None + + @staticmethod + def _require_herdr() -> None: + if herdr.resolve_executable() is None: + raise ProviderUnavailableError( + "herdr is not available: set HERDR_BIN_PATH or install herdr on PATH" + ) diff --git a/src/git_workspace/cli/__init__.py b/src/git_workspace/cli/__init__.py index 309ab9e..c31533b 100644 --- a/src/git_workspace/cli/__init__.py +++ b/src/git_workspace/cli/__init__.py @@ -10,6 +10,7 @@ edit_command, init_command, list_command, + prepare_command, prune_command, remove_command, reset_command, @@ -39,6 +40,7 @@ app.add_typer(edit_command) app.add_typer(init_command) app.add_typer(list_command) +app.add_typer(prepare_command) app.add_typer(prune_command) app.add_typer(remove_command) app.add_typer(reset_command) diff --git a/src/git_workspace/cli/commands/__init__.py b/src/git_workspace/cli/commands/__init__.py index fdb5af1..a7bbd65 100644 --- a/src/git_workspace/cli/commands/__init__.py +++ b/src/git_workspace/cli/commands/__init__.py @@ -6,6 +6,7 @@ from git_workspace.cli.commands.edit import app as edit_command from git_workspace.cli.commands.init import app as init_command from git_workspace.cli.commands.list import app as list_command +from git_workspace.cli.commands.prepare import app as prepare_command from git_workspace.cli.commands.prune import app as prune_command from git_workspace.cli.commands.remove import app as remove_command from git_workspace.cli.commands.reset import app as reset_command @@ -20,6 +21,7 @@ "edit_command", "init_command", "list_command", + "prepare_command", "prune_command", "remove_command", "reset_command", diff --git a/src/git_workspace/cli/commands/down.py b/src/git_workspace/cli/commands/down.py index f79cbab..004c07f 100644 --- a/src/git_workspace/cli/commands/down.py +++ b/src/git_workspace/cli/commands/down.py @@ -2,10 +2,10 @@ import typer -from git_workspace import operations from git_workspace.cli.parsers import parse_vars from git_workspace.ui import console, styled_branch from git_workspace.workspace import Workspace +from git_workspace.workspace.service import WorkspaceService app = typer.Typer() @@ -47,8 +47,8 @@ def down( console.print(f"Deactivating {styled_branch(worktree.branch)}") - operations.deactivate_worktree( - worktree, + WorkspaceService.create(workspace).down( + worktree.branch, runtime_vars=dict(runtime_vars or []), # ty:ignore[no-matching-overload] effective_branch=effective_branch, ) diff --git a/src/git_workspace/cli/commands/prepare.py b/src/git_workspace/cli/commands/prepare.py new file mode 100644 index 0000000..fbb3cf0 --- /dev/null +++ b/src/git_workspace/cli/commands/prepare.py @@ -0,0 +1,79 @@ +from pathlib import Path +from typing import Annotated + +import typer + +from git_workspace.cli.parsers import parse_vars +from git_workspace.ui import console, styled_path +from git_workspace.workspace import Workspace +from git_workspace.workspace.core import WorkspaceResolver +from git_workspace.workspace.models import ProviderKind +from git_workspace.workspace.service import WorkspaceService + +app = typer.Typer() + + +@app.command() +def prepare( + ctx: typer.Context, + path: Annotated[ + str | None, + typer.Argument( + help="Path to the worktree to prepare. Defaults to the current working directory.", + ), + ] = None, + force: Annotated[ + bool, + typer.Option( + "--force/--no-force", + help="Re-run preparation even if the worktree is already prepared", + ), + ] = False, + runtime_vars: Annotated[ + list[str] | None, + typer.Option( + "-v", + "--var", + help="A variable that will be forwarded to the workspace's hook scripts. May be specified multiple times", + callback=parse_vars, + ), + ] = None, + effective_branch: Annotated[ + str | None, + typer.Option( + "-a", + "--as", + help="Treat the worktree as if it were on this branch when evaluating hook conditions. Does not change the actual branch or GIT_WORKSPACE_BRANCH.", + ), + ] = None, +) -> None: + """ + Prepare an existing worktree. + + Applies copies and links from the manifest and runs on_setup hooks for a worktree that already exists — regardless of which tool created it. Works from any path inside the worktree, including worktrees created directly with `git worktree add` or by external tools. + + Skips preparation when the worktree is already prepared; pass --force to re-run it. Never creates a worktree, never runs on_attach hooks, and is safe to retry after a failure. + """ + target = (Path(path) if path else Path.cwd()).expanduser().resolve() + + if ctx.obj.workspace_dir is not None: + workspace = Workspace.resolve(ctx.obj.workspace_dir) + else: + workspace = WorkspaceResolver.resolve_from_worktree(target) + + console.print(f"Preparing {styled_path(target)}") + + # Preparation must work anywhere (CI, external hosts) with git alone, so + # the import step is pinned to the native provider regardless of backend. + service = WorkspaceService.create(workspace, provider_kind=ProviderKind.NATIVE_GIT) + outcome = service.prepare_path( + target, + force=force, + runtime_vars=dict(runtime_vars or []), # ty:ignore[no-matching-overload] + effective_branch=effective_branch, + ) + + if outcome.skipped: + console.success("Already prepared (use --force to re-run setup)") + else: + console.success("Done") diff --git a/src/git_workspace/cli/commands/prune.py b/src/git_workspace/cli/commands/prune.py index adaf72c..838481e 100644 --- a/src/git_workspace/cli/commands/prune.py +++ b/src/git_workspace/cli/commands/prune.py @@ -5,6 +5,7 @@ from git_workspace.ui import console, styled_branch from git_workspace.workspace import Workspace +from git_workspace.workspace.service import WorkspaceService app = typer.Typer() @@ -33,27 +34,19 @@ def prune( Identifies and removes worktrees older than a specified threshold. The age threshold is taken from --older-than-days if provided, otherwise from the [prune] section in the manifest. Branches listed in exclude_branches are never removed regardless of age. Runs in dry-run mode by default. Pass --apply to actually remove worktrees. + + Removal goes through the full lifecycle: on_detach and on_teardown hooks run for each worktree before it is deleted. A failing worktree is skipped and pruning continues; failures are reported at the end. """ workspace = Workspace.resolve(ctx.obj.workspace_dir) - threshold = older_than_days - if threshold is None: - if workspace.manifest.prune is None: - raise typer.BadParameter( - "Must pass --older-than-days or define [prune] in manifest", - param_hint="'--older-than-days'", - ) - threshold = workspace.manifest.prune.older_than_days - - protected: set[str] = set() - if workspace.manifest.prune: - protected.update(workspace.manifest.prune.exclude_branches) + if older_than_days is None and workspace.manifest.prune is None: + raise typer.BadParameter( + "Must pass --older-than-days or define [prune] in manifest", + param_hint="'--older-than-days'", + ) - candidates = [ - worktree - for worktree in workspace.list_worktrees() - if worktree.age_days > threshold and worktree.branch not in protected - ] + service = WorkspaceService.create(workspace) + candidates = service.prune_candidates(older_than_days=older_than_days) if not candidates: console.success("Nothing to prune") @@ -70,7 +63,14 @@ def prune( console.print(table) else: console.print(f"Pruning [bold]{len(candidates)}[/bold] worktree(s)...") - for worktree in candidates: - console.print(f" Removing {styled_branch(worktree.branch)}") - worktree.delete(force=True) + failures = service.prune(candidates) + + if failures: + for failure in failures: + console.warning( + f"Failed to remove {styled_branch(failure.worktree.branch)}: {failure.error}" + ) + console.error(f"Pruned with {len(failures)} failure(s)") + raise typer.Exit(code=1) + console.success("Done") diff --git a/src/git_workspace/cli/commands/remove.py b/src/git_workspace/cli/commands/remove.py index acc4962..7e0ad96 100644 --- a/src/git_workspace/cli/commands/remove.py +++ b/src/git_workspace/cli/commands/remove.py @@ -2,10 +2,10 @@ import typer -from git_workspace import operations from git_workspace.cli.parsers import parse_vars from git_workspace.ui import console, styled_branch from git_workspace.workspace import Workspace +from git_workspace.workspace.service import WorkspaceService app = typer.Typer() @@ -58,10 +58,10 @@ def remove( console.print(f"Removing {styled_branch(worktree.branch)}") - operations.remove_worktree( - worktree, - runtime_vars=dict(runtime_vars or []), # ty:ignore[no-matching-overload] + WorkspaceService.create(workspace).remove( + worktree.branch, force=force, + runtime_vars=dict(runtime_vars or []), # ty:ignore[no-matching-overload] effective_branch=effective_branch, ) diff --git a/src/git_workspace/cli/commands/reset.py b/src/git_workspace/cli/commands/reset.py index a810ef9..bddad46 100644 --- a/src/git_workspace/cli/commands/reset.py +++ b/src/git_workspace/cli/commands/reset.py @@ -2,15 +2,16 @@ import typer -from git_workspace import operations from git_workspace.cli.parsers import parse_vars from git_workspace.ui import console, styled_branch from git_workspace.workspace import Workspace +from git_workspace.workspace.models import ProviderKind +from git_workspace.workspace.service import WorkspaceService app = typer.Typer() -@app.command() +@app.command(deprecated=True) def reset( ctx: typer.Context, branch: Annotated[ @@ -43,14 +44,19 @@ def reset( Re-applies copies and links from the manifest, updates the managed ignore rules, and reruns setup hooks. Intended for repairing or refreshing an existing workspace when its state has drifted (e.g. missing dependencies, removed files, or updated configuration). Does not modify Git history, switch branches, or discard uncommitted changes. + + Deprecated: use `git workspace prepare --force` instead. """ + console.warning("'reset' is deprecated; use 'git workspace prepare --force' instead") + workspace = Workspace.resolve(ctx.obj.workspace_dir) worktree = workspace.resolve_worktree(branch) console.print(f"Resetting {styled_branch(worktree.branch)}") - operations.reset_worktree( - worktree, + WorkspaceService.create(workspace, provider_kind=ProviderKind.NATIVE_GIT).prepare_path( + worktree.dir, + force=True, runtime_vars=dict(runtime_vars or []), # ty:ignore[no-matching-overload] effective_branch=effective_branch, ) diff --git a/src/git_workspace/cli/commands/up.py b/src/git_workspace/cli/commands/up.py index 72e5ab5..80080ab 100644 --- a/src/git_workspace/cli/commands/up.py +++ b/src/git_workspace/cli/commands/up.py @@ -2,10 +2,12 @@ import typer -from git_workspace import operations from git_workspace.cli.parsers import parse_vars from git_workspace.ui import console, styled_branch, styled_path from git_workspace.workspace import Workspace +from git_workspace.workspace.models import PresenterKind, ProviderKind +from git_workspace.workspace.service import WorkspaceService +from git_workspace.workspace.worktree import Worktree app = typer.Typer() @@ -62,6 +64,34 @@ def up( help="Print the worktree path to stdout and suppress all other output.", ), ] = False, + backend: Annotated[ + str | None, + typer.Option( + "--backend", + help="Backend preset to use: native, herdr, or auto (verified environment detection). Overrides the manifest's [workspace] configuration.", + ), + ] = None, + provider: Annotated[ + ProviderKind | None, + typer.Option( + "--provider", + help="Explicit worktree provider; overrides the backend preset's provider.", + ), + ] = None, + presenter: Annotated[ + PresenterKind | None, + typer.Option( + "--presenter", + help="Explicit workspace presenter; overrides the backend preset's presenter.", + ), + ] = None, + focus: Annotated[ + bool, + typer.Option( + "--focus/--no-focus", + help="Focus the workspace presentation after activation (when the backend has a presenter).", + ), + ] = True, ) -> None: """ Spawns a worktree, setting it up first if needed. @@ -71,15 +101,24 @@ def up( If the worktree does not exist, copies and links from the manifest are applied first, followed by on_setup hooks. Unless --detached is passed, on_attach hooks also run — use --detached for headless or automated workflows. """ workspace = Workspace.resolve(ctx.obj.workspace_dir) - worktree = workspace.resolve_or_create_worktree(branch, base_branch) + if branch is None: + branch = Worktree.resolve(workspace, None).branch - console.print(f"Activating {styled_branch(worktree.branch)}") + console.print(f"Activating {styled_branch(branch)}") - operations.activate_worktree( - worktree, + service = WorkspaceService.create( + workspace, + backend_name=backend, + provider_kind=provider, + presenter_kind=presenter, + ) + worktree = service.up( + branch, + base_branch=base_branch, runtime_vars=dict(runtime_vars or []), # ty:ignore[no-matching-overload] detached=detached, effective_branch=effective_branch, + focus=focus, ) console.success(f"Worktree ready at {styled_path(worktree.dir)}") diff --git a/src/git_workspace/doctor.py b/src/git_workspace/doctor.py index 17e6644..3089e2f 100644 --- a/src/git_workspace/doctor.py +++ b/src/git_workspace/doctor.py @@ -10,13 +10,13 @@ import tomlkit from jinja2 import Environment, TemplateSyntaxError -from git_workspace import git -from git_workspace.assets import Copier -from git_workspace.env import BASE_VAR_KEYS, FINGERPRINT_VAR_PREFIX from git_workspace.errors import WorktreeListingError -from git_workspace.fingerprint import SUPPORTED_ALGORITHMS from git_workspace.manifest import KNOWN_CONDITION_KEYS, HookGroup, Manifest +from git_workspace.subprocesses import git from git_workspace.utils import normalize_variable_name +from git_workspace.workspace.assets import Copier +from git_workspace.workspace.env import BASE_VAR_KEYS, FINGERPRINT_VAR_PREFIX +from git_workspace.workspace.fingerprint import SUPPORTED_ALGORITHMS if TYPE_CHECKING: from git_workspace.workspace import Workspace @@ -568,6 +568,37 @@ def _check_stale_worktrees(workspace: Workspace, findings: list[Finding]) -> Non ) +def _check_workspace_state(workspace: Workspace, findings: list[Finding]) -> None: + from git_workspace.workspace.state import WorkspaceStateStore + + store = WorkspaceStateStore(workspace.paths.state) + for record in store.list(): + worktree_path = record.worktree.worktree_path + + if not worktree_path.exists(): + findings.append( + Finding( + "warning", + f"Workspace state exists for '{record.worktree.branch}' but its worktree" + f" '{worktree_path}' no longer exists", + fix=Fix( + label=f"Delete stale state for '{record.worktree.branch}'", + kind="auto", + apply=lambda p=worktree_path: store.delete(p), + ), + ) + ) + elif record.lifecycle_state.value == "preparation-failed": + findings.append( + Finding( + "warning", + f"Preparation failed for '{record.worktree.branch}'" + f" ({record.preparation_error}); retry with:" + f" git workspace prepare --force {worktree_path}", + ) + ) + + def _check_hook_unknown_condition_keys(workspace: Workspace, findings: list[Finding]) -> None: known_str = ", ".join(sorted(KNOWN_CONDITION_KEYS)) for event, groups in _iter_hooks(workspace): @@ -657,5 +688,6 @@ def run_checks(workspace: Workspace) -> list[Finding]: _check_copy_placeholders(workspace, findings) _check_base_branch(workspace, findings) _check_stale_worktrees(workspace, findings) + _check_workspace_state(workspace, findings) return findings diff --git a/src/git_workspace/errors.py b/src/git_workspace/errors.py index cd82dae..5fa4d0e 100644 --- a/src/git_workspace/errors.py +++ b/src/git_workspace/errors.py @@ -1,3 +1,9 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from git_workspace.workspace.models import WorkspaceRecord + + class GitWorkspaceError(Exception): """Base class for all git-workspace errors.""" @@ -30,14 +36,83 @@ class GitFetchError(GitWorkspaceError): """Raised when a `git fetch` operation fails.""" -class WorktreeCreationError(GitWorkspaceError): +class WorkspaceBackendError(GitWorkspaceError): + """Base class for provider and presenter failures.""" + + +class ProviderError(WorkspaceBackendError): + """Base class for worktree-provider failures.""" + + +class ProviderUnavailableError(ProviderError): + """Raised when a worktree provider cannot currently be used.""" + + +class WorktreeCreationError(ProviderError): """Raised when a git worktree cannot be created.""" -class WorktreeRemovalError(GitWorkspaceError): +class WorktreeAlreadyExistsError(WorktreeCreationError): + """Raised when worktree creation targets a path that is already occupied.""" + + +class WorktreeRemovalError(ProviderError): """Raised when a git worktree cannot be removed.""" +class UnsafeWorktreeRemovalError(WorktreeRemovalError): + """Raised when removing a dirty worktree is refused without force.""" + + +class WorktreeListingError(ProviderError): + """Raised when `git worktree list` fails or produces unparseable output.""" + + +class WorktreeResolutionError(GitWorkspaceError): + """Raised when a worktree cannot be resolved from the given branch or working directory.""" + + +class WorktreeNotFoundError(WorktreeResolutionError): + """Raised when a path does not correspond to a registered git worktree.""" + + +class PresenterError(WorkspaceBackendError): + """Base class for workspace-presenter failures.""" + + +class PresenterUnavailableError(PresenterError): + """Raised when a workspace presenter cannot currently be used.""" + + +class PresenterCapabilityError(PresenterError): + """Raised when a presenter is asked to perform an operation it does not support.""" + + +class PresentationNotFoundError(PresenterError): + """Raised when no presentation exists for a worktree.""" + + +class HerdrError(WorkspaceBackendError): + """Raised when the herdr CLI reports an error or returns malformed output.""" + + def __init__(self, message: str, *, code: str | None = None) -> None: + self.code = code + super().__init__(message) + + +class ExternalCommandError(WorkspaceBackendError): + """Raised when an external command exits with a non-zero return code.""" + + def __init__(self, *, command: list[str], exit_code: int, stdout: str, stderr: str) -> None: + self.command = command + self.exit_code = exit_code + self.stdout = stdout + self.stderr = stderr + super().__init__( + f"Command {' '.join(command)!r} failed with exit code {exit_code}: {stderr.strip()}" + ) + + class WorkspaceLinkError(GitWorkspaceError): """Raised when a symlink cannot be created due to a conflict at the target path.""" @@ -50,12 +125,24 @@ class HookExecutionError(GitWorkspaceError): """Raised when a hook script exits with a non-zero return code.""" -class WorktreeListingError(GitWorkspaceError): - """Raised when `git worktree list` fails or produces unparseable output.""" +class WorkspacePreparationError(GitWorkspaceError): + """Raised when workspace preparation fails; carries the persisted failure record.""" + def __init__(self, record: WorkspaceRecord, *, cause: str) -> None: + self.record = record + worktree_path = record.worktree.worktree_path + super().__init__( + f"Failed to prepare workspace at {worktree_path}: {cause}\n" + f"The worktree was preserved. Retry with: git workspace prepare {worktree_path}" + ) -class WorktreeResolutionError(GitWorkspaceError): - """Raised when a worktree cannot be resolved from the given branch or working directory.""" + +class WorkspaceLockedError(GitWorkspaceError): + """Raised when another workspace operation holds the lifecycle lock for a worktree.""" + + +class WorkspaceStateError(GitWorkspaceError): + """Raised when a persisted workspace state file cannot be used (e.g. newer schema).""" class CacheError(GitWorkspaceError): diff --git a/src/git_workspace/manifest.py b/src/git_workspace/manifest.py index 8116183..fed1093 100644 --- a/src/git_workspace/manifest.py +++ b/src/git_workspace/manifest.py @@ -95,6 +95,24 @@ class Hooks: on_teardown: list[HookGroup] = field(default_factory=list) +@dataclass +class WorkspaceSettings: + """ + Backend selection from the manifest's ``[workspace]`` table. + + - backend: named preset ("native", "herdr") or "auto" for verified + environment detection + - provider: explicit worktree-provider kind; overrides the preset's provider + - presenter: explicit presenter kind; overrides the preset's presenter + + Values are plain strings here; the backend resolver validates them. + """ + + backend: str | None = None + provider: str | None = None + presenter: str | None = None + + @dataclass class Prune: """ @@ -165,6 +183,7 @@ class Manifest: fingerprints: list[Fingerprint] = field(default_factory=list) hooks: Hooks = field(default_factory=Hooks) prune: Prune | None = None + workspace: WorkspaceSettings = field(default_factory=WorkspaceSettings) @classmethod def _parse_version(cls, data: dict[str, Any]) -> int: @@ -240,6 +259,15 @@ def _parse_hooks(cls, data: dict[str, Any]) -> Hooks: on_teardown=[cls._parse_hook_group(g) for g in hooks_data.get("on_teardown", [])], ) + @classmethod + def _parse_workspace(cls, data: dict[str, Any]) -> WorkspaceSettings: + workspace_data = data.get("workspace", {}) + return WorkspaceSettings( + backend=workspace_data.get("backend"), + provider=workspace_data.get("provider"), + presenter=workspace_data.get("presenter"), + ) + @classmethod def _parse_prune(cls, data: dict[str, Any]) -> Prune | None: prune_data = data.get("prune") @@ -300,4 +328,14 @@ def load(cls, workspace: Workspace) -> Manifest: len(hooks.on_teardown), f"older_than_days={prune.older_than_days}" if prune else None, ) - return Manifest(version, base_branch, copies, links, vars, fingerprints, hooks, prune) + return Manifest( + version, + base_branch, + copies, + links, + vars, + fingerprints, + hooks, + prune, + cls._parse_workspace(data), + ) diff --git a/src/git_workspace/operations.py b/src/git_workspace/operations.py deleted file mode 100644 index 1f1dc23..0000000 --- a/src/git_workspace/operations.py +++ /dev/null @@ -1,144 +0,0 @@ -import functools -import logging -from collections.abc import Callable -from typing import Any - -from git_workspace.assets import Copier, IgnoreManager, Linker -from git_workspace.env import build_env -from git_workspace.fingerprint import compute_fingerprints -from git_workspace.hooks import HookRunner -from git_workspace.worktree import Worktree - -logger = logging.getLogger(__name__) - - -def _env(fn: Callable[..., Any]) -> Callable[..., Any]: - @functools.wraps(fn) - def wrapper(worktree: Worktree, runtime_vars: dict[str, str], *args: Any, **kwargs: Any) -> Any: - fingerprint_vars = compute_fingerprints(worktree, worktree.workspace.manifest.fingerprints) - env = build_env(worktree, runtime_vars, fingerprint_vars) - return fn(worktree, runtime_vars, env, *args, **kwargs) - - return wrapper - - -def _apply_assets(worktree: Worktree, env: dict[str, str]) -> None: - with IgnoreManager(worktree) as ignore: - Copier(worktree, ignore, env).apply() - Linker(worktree, ignore).apply() - - -@_env -def activate_worktree( - worktree: Worktree, - runtime_vars: dict[str, str], - env: dict[str, str], - *, - detached: bool, - effective_branch: str | None = None, -) -> None: - """ - Apply assets and run setup/attach hooks when entering a worktree. - - For new worktrees, assets are applied first and then ``on_setup`` hooks are - run. If ``detached`` is ``False``, ``on_attach`` hooks are also run. - - :param worktree: The worktree being activated. - :param runtime_vars: Extra variables to inject into the hook environment. - :param detached: If ``True``, ``on_attach`` hooks are skipped. - :param effective_branch: Branch name used to evaluate hook conditions. Defaults - to the worktree's real branch when not provided. - """ - logger.debug( - "activating worktree %r (is_new=%s, detached=%s)", - worktree.branch, - worktree.is_new, - detached, - ) - - if worktree.is_new: - _apply_assets(worktree, env) - - effective = effective_branch or worktree.branch - with HookRunner(worktree, env=env, effective_branch=effective) as hook_runner: - if worktree.is_new: - hook_runner.run_on_setup_hooks() - - if not detached: - hook_runner.run_on_attach_hooks() - - -@_env -def reset_worktree( - worktree: Worktree, - runtime_vars: dict[str, str], - env: dict[str, str], - *, - effective_branch: str | None = None, -) -> None: - """ - Re-apply assets and re-run ``on_setup`` hooks for an existing worktree. - - :param worktree: The worktree being reset. - :param runtime_vars: Extra variables to inject into the hook environment. - :param effective_branch: Branch name used to evaluate hook conditions. Defaults - to the worktree's real branch when not provided. - """ - logger.debug("resetting worktree %r", worktree.branch) - _apply_assets(worktree, env) - - effective = effective_branch or worktree.branch - with HookRunner(worktree, env=env, effective_branch=effective) as hook_runner: - hook_runner.run_on_setup_hooks() - - -@_env -def deactivate_worktree( - worktree: Worktree, - runtime_vars: dict[str, str], - env: dict[str, str], - *, - effective_branch: str | None = None, -) -> None: - """ - Run ``on_detach`` hooks when leaving a worktree without removing it. - - :param worktree: The worktree being deactivated. - :param runtime_vars: Extra variables to inject into the hook environment. - :param effective_branch: Branch name used to evaluate hook conditions. Defaults - to the worktree's real branch when not provided. - """ - logger.debug("deactivating worktree %r", worktree.branch) - effective = effective_branch or worktree.branch - with HookRunner(worktree, env=env, effective_branch=effective) as hook_runner: - hook_runner.run_on_detach_hooks() - - -@_env -def remove_worktree( - worktree: Worktree, - runtime_vars: dict[str, str], - env: dict[str, str], - *, - force: bool, - effective_branch: str | None = None, -) -> None: - """ - Run detach and teardown hooks, then delete the worktree. - - Runs ``on_detach`` hooks first, then ``on_teardown`` hooks, and finally - removes the worktree directory. - - :param worktree: The worktree to remove. - :param runtime_vars: Extra variables to inject into the hook environment. - :param force: If ``True``, passes ``--force`` to the underlying ``git worktree remove`` call. - :param effective_branch: Branch name used to evaluate hook conditions. Defaults - to the worktree's real branch when not provided. - """ - logger.debug("removing worktree %r (force=%s)", worktree.branch, force) - effective = effective_branch or worktree.branch - with HookRunner(worktree, env=env, effective_branch=effective) as hook_runner: - hook_runner.run_on_detach_hooks() - hook_runner.run_on_teardown_hooks() - - worktree.delete(force) diff --git a/src/git_workspace/presenters/__init__.py b/src/git_workspace/presenters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/git_workspace/presenters/base.py b/src/git_workspace/presenters/base.py new file mode 100644 index 0000000..02a511b --- /dev/null +++ b/src/git_workspace/presenters/base.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import builtins +from pathlib import Path +from typing import Protocol, runtime_checkable + +from git_workspace.workspace.models import ( + ManagedWorktree, + Presentation, + PresenterCapabilities, + PresenterKind, +) + + +@runtime_checkable +class WorkspacePresenter(Protocol): + """ + Owns the presentation of a worktree: an external runtime or UI such as an + editor window or terminal session. + + A presenter must never create git branches, invoke `git worktree add`, + remove worktrees, or run workspace preparation/teardown. Operations not + supported by an implementation (see `capabilities`) must raise + PresenterCapabilityError instead of silently claiming success. + """ + + @property + def kind(self) -> PresenterKind: + """Return the stable presenter identifier.""" + ... + + @property + def capabilities(self) -> PresenterCapabilities: + """Return what this presenter reliably supports.""" + ... + + def is_available(self) -> bool: + """ + Return whether the presenter can currently be used. + + Must not mutate state. + """ + ... + + def open(self, worktree: ManagedWorktree) -> Presentation: + """ + Open or register a presentation for the worktree. + + Must be idempotent where the underlying platform allows it. If a + presentation already exists, return it instead of creating a duplicate. + """ + ... + + def find(self, worktree_path: Path) -> Presentation | None: + """ + Find the presentation associated with a canonical worktree path. + + Must not mutate state. + """ + ... + + def focus( + self, + worktree: ManagedWorktree, + presentation: Presentation | None = None, + ) -> Presentation: + """ + Focus an existing presentation. + + May call open() when no presentation exists, if documented by the + implementation. + """ + ... + + def close( + self, + worktree: ManagedWorktree, + presentation: Presentation | None = None, + ) -> None: + """ + Close or detach the presentation while preserving the git worktree. + + Must be idempotent. + """ + ... + + def list(self, repository_path: Path | None = None) -> builtins.list[tuple[Path, Presentation]]: + """ + List worktree-path-to-presentation mappings visible to this presenter. + """ + ... diff --git a/src/git_workspace/presenters/herdr.py b/src/git_workspace/presenters/herdr.py new file mode 100644 index 0000000..92048ee --- /dev/null +++ b/src/git_workspace/presenters/herdr.py @@ -0,0 +1,181 @@ +import builtins +import logging +from pathlib import Path + +from git_workspace.errors import ( + HerdrError, + PresentationNotFoundError, + PresenterError, + PresenterUnavailableError, +) +from git_workspace.subprocesses import herdr +from git_workspace.subprocesses.runner import DEFAULT_RUNNER, CommandRunner +from git_workspace.workspace.models import ( + ManagedWorktree, + Presentation, + PresenterCapabilities, + PresenterKind, +) + +logger = logging.getLogger(__name__) + +_CAPABILITIES = PresenterCapabilities( + can_find_existing=True, + can_focus_existing=True, + can_close=True, + can_list=True, + supports_runtime_identity=True, +) + + +class HerdrPresenter: + """ + Presents worktrees as herdr workspaces. + + Never creates branches, invokes `git worktree add`, removes worktrees, or + runs preparation — it only opens, focuses, closes, and lists workspaces. + """ + + def __init__( + self, + runner: CommandRunner = DEFAULT_RUNNER, + executable: str | None = None, + ) -> None: + self._runner = runner + self._explicit_executable = executable + + @property + def kind(self) -> PresenterKind: + return PresenterKind.HERDR + + @property + def capabilities(self) -> PresenterCapabilities: + return _CAPABILITIES + + def is_available(self) -> bool: + return herdr.resolve_executable(self._explicit_executable) is not None + + def open(self, worktree: ManagedWorktree) -> Presentation: + try: + result = herdr.open_worktree( + worktree.repository_path, + worktree.worktree_path, + executable=self._executable(), + runner=self._runner, + ) + except HerdrError as e: + raise PresenterError( + f"Herdr failed to open a workspace for {worktree.worktree_path}: {e}" + ) from e + + workspace_id = result.get("workspace", {}).get("workspace_id") + if workspace_id is None: + raise PresenterError( + f"Herdr did not return a workspace id for {worktree.worktree_path}" + ) + return self._presentation(workspace_id) + + def find(self, worktree_path: Path) -> Presentation | None: + canonical = worktree_path.expanduser().resolve() + + try: + result = herdr.list_worktrees( + canonical, executable=self._executable(), runner=self._runner + ) + except HerdrError as e: + if e.code == "not_git_worktree": + return None + raise PresenterError(f"Herdr failed to inspect {canonical}: {e}") from e + + workspace_id = next( + ( + entry.get("open_workspace_id") + for entry in result.get("worktrees", []) + if Path(entry.get("path", "")) == canonical and entry.get("open_workspace_id") + ), + None, + ) + return self._presentation(workspace_id) if workspace_id else None + + def focus( + self, + worktree: ManagedWorktree, + presentation: Presentation | None = None, + ) -> Presentation: + """ + Focuses the workspace presenting the worktree, opening one when none + exists yet. + """ + target = presentation or self.find(worktree.worktree_path) or self.open(worktree) + assert target.presentation_id is not None + + try: + herdr.focus_workspace( + target.presentation_id, executable=self._executable(), runner=self._runner + ) + except HerdrError as e: + if e.code == "workspace_not_found": + raise PresentationNotFoundError( + f"Herdr workspace {target.presentation_id} no longer exists" + ) from e + raise PresenterError( + f"Herdr failed to focus workspace {target.presentation_id}: {e}" + ) from e + return target + + def close( + self, + worktree: ManagedWorktree, + presentation: Presentation | None = None, + ) -> None: + target = presentation or self.find(worktree.worktree_path) + if target is None or target.presentation_id is None: + return + + try: + herdr.close_workspace( + target.presentation_id, executable=self._executable(), runner=self._runner + ) + except HerdrError as e: + if e.code == "workspace_not_found": + return + raise PresenterError( + f"Herdr failed to close workspace {target.presentation_id}: {e}" + ) from e + + def list(self, repository_path: Path | None = None) -> builtins.list[tuple[Path, Presentation]]: + if repository_path is None: + raise PresenterError( + "The herdr presenter lists presentations per repository; " + "a repository path is required" + ) + + try: + result = herdr.list_worktrees( + repository_path, executable=self._executable(), runner=self._runner + ) + except HerdrError as e: + raise PresenterError( + f"Herdr failed to list workspaces for {repository_path}: {e}" + ) from e + + return [ + (Path(entry["path"]), self._presentation(entry["open_workspace_id"])) + for entry in result.get("worktrees", []) + if entry.get("open_workspace_id") and entry.get("is_linked_worktree") + ] + + def _executable(self) -> str: + executable = herdr.resolve_executable(self._explicit_executable) + if executable is None: + raise PresenterUnavailableError( + "herdr is not available: set HERDR_BIN_PATH or install herdr on PATH" + ) + return executable + + @staticmethod + def _presentation(workspace_id: str) -> Presentation: + return Presentation( + presenter_kind=PresenterKind.HERDR, + presentation_id=workspace_id, + ) diff --git a/src/git_workspace/providers/__init__.py b/src/git_workspace/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/git_workspace/providers/base.py b/src/git_workspace/providers/base.py new file mode 100644 index 0000000..64ebc5f --- /dev/null +++ b/src/git_workspace/providers/base.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import builtins +from pathlib import Path +from typing import Protocol, runtime_checkable + +from git_workspace.workspace.models import ManagedWorktree, ProviderKind, WorktreeRequest + + +@runtime_checkable +class WorktreeProvider(Protocol): + """ + Owns the git worktree lifecycle: creation, discovery, and removal. + + A provider must never expose presentation behavior (focus, close, attach, + open_window) and must never run workspace preparation. + """ + + @property + def kind(self) -> ProviderKind: + """Return the stable provider identifier.""" + ... + + def is_available(self) -> bool: + """ + Return whether the provider can currently be used. + + Must not mutate state. + """ + ... + + def create(self, request: WorktreeRequest) -> ManagedWorktree: + """ + Create and register a git worktree. + + Must return only after the worktree exists. Must not run workspace + preparation. Not inherently idempotent: when the target already exists + it must either return the verified matching worktree or raise + WorktreeAlreadyExistsError — never attach to an unrelated directory. + """ + ... + + def import_existing(self, worktree_path: Path) -> ManagedWorktree: + """ + Import or describe an existing registered git worktree. + + Must be idempotent. Must not prepare or present the worktree. + Raises WorktreeNotFoundError when the path is not a registered worktree. + """ + ... + + def find(self, worktree_path: Path) -> ManagedWorktree | None: + """ + Find a managed worktree by canonical path. + + Must not mutate provider state. + """ + ... + + def list(self, repository_path: Path | None = None) -> builtins.list[ManagedWorktree]: + """ + List worktrees visible to this provider. + + When repository_path is supplied, filter to that repository. + """ + ... + + def remove(self, worktree: ManagedWorktree, *, force: bool = False) -> None: + """ + Remove the git worktree. + + Must not delete the branch. Must refuse dirty or unsafe removal unless + force=True. + """ + ... diff --git a/src/git_workspace/providers/herdr.py b/src/git_workspace/providers/herdr.py new file mode 100644 index 0000000..5612f21 --- /dev/null +++ b/src/git_workspace/providers/herdr.py @@ -0,0 +1,200 @@ +import builtins +import logging +from pathlib import Path + +from git_workspace.errors import ( + HerdrError, + ProviderError, + ProviderUnavailableError, + WorktreeAlreadyExistsError, + WorktreeCreationError, + WorktreeNotFoundError, + WorktreeRemovalError, +) +from git_workspace.subprocesses import git, herdr +from git_workspace.subprocesses.runner import DEFAULT_RUNNER, CommandRunner +from git_workspace.workspace.models import ManagedWorktree, ProviderKind, WorktreeRequest + +logger = logging.getLogger(__name__) + + +class HerdrWorktreeProvider: + """ + Worktree provider that delegates worktree ownership to herdr. + + Herdr opens a workspace presentation as a side effect of creating a + worktree; the provider records the returned workspace id as metadata but + never focuses or closes anything — that belongs to HerdrPresenter. + + Herdr removes worktrees by their open workspace id, so removal of a + worktree that is not presented in herdr falls back to plain git removal. + """ + + def __init__( + self, + runner: CommandRunner = DEFAULT_RUNNER, + executable: str | None = None, + ) -> None: + self._runner = runner + self._explicit_executable = executable + + @property + def kind(self) -> ProviderKind: + return ProviderKind.HERDR + + def is_available(self) -> bool: + return herdr.resolve_executable(self._explicit_executable) is not None + + def create(self, request: WorktreeRequest) -> ManagedWorktree: + repository_path = request.repository_path + target = request.target_path or (repository_path / request.branch).resolve() + + if any(wt.worktree_path == target for wt in self.list(repository_path)): + raise WorktreeAlreadyExistsError(f"A worktree already exists at {target}") + + try: + result = herdr.create_worktree( + repository_path, + request.branch, + target, + request.base_branch, + executable=self._executable(), + runner=self._runner, + ) + except HerdrError as e: + raise WorktreeCreationError( + f"Herdr failed to create a worktree for branch {request.branch!r}: {e}" + ) from e + + entry = result.get("worktree", {}) + return self._managed( + repository_path, + Path(entry.get("path", target)), + entry.get("branch", request.branch), + workspace_id=entry.get("open_workspace_id"), + ) + + def import_existing(self, worktree_path: Path) -> ManagedWorktree: + worktree = self.find(worktree_path) + if worktree is None: + raise WorktreeNotFoundError( + f"No registered git worktree found at {worktree_path.expanduser().resolve()}" + ) + return worktree + + def find(self, worktree_path: Path) -> ManagedWorktree | None: + canonical = worktree_path.expanduser().resolve() + if not canonical.is_dir(): + return None + + try: + result = herdr.list_worktrees( + canonical, executable=self._executable(), runner=self._runner + ) + except HerdrError as e: + if e.code == "not_git_worktree": + return None + raise ProviderError(f"Herdr failed to inspect {canonical}: {e}") from e + + repository_path = Path(result.get("source", {}).get("repo_root", canonical)) + return next( + ( + wt + for wt in self._managed_entries(repository_path, result) + if wt.worktree_path == canonical + ), + None, + ) + + def list(self, repository_path: Path | None = None) -> builtins.list[ManagedWorktree]: + if repository_path is None: + raise WorktreeNotFoundError( + "The herdr provider lists worktrees per repository; a repository path is required" + ) + + try: + result = herdr.list_worktrees( + repository_path, executable=self._executable(), runner=self._runner + ) + except HerdrError as e: + raise ProviderError(f"Herdr failed to list worktrees in {repository_path}: {e}") from e + + return self._managed_entries(repository_path, result) + + def remove(self, worktree: ManagedWorktree, *, force: bool = False) -> None: + current = self.find(worktree.worktree_path) + if current is None: + raise WorktreeNotFoundError( + f"No registered git worktree found at {worktree.worktree_path}" + ) + + workspace_id = current.metadata.get("workspace_id") + if workspace_id is None: + # Not presented in herdr: herdr cannot address it, remove via git. + logger.debug( + "worktree %s has no herdr workspace; removing via git", worktree.worktree_path + ) + git.remove_worktree( + worktree.worktree_path, + force, + cwd=worktree.repository_path, + runner=self._runner, + ) + return + + try: + herdr.remove_worktree( + workspace_id, + force=force, + executable=self._executable(), + runner=self._runner, + ) + except HerdrError as e: + if worktree.worktree_path.exists() and self.find(worktree.worktree_path) is None: + raise WorktreeRemovalError( + "Herdr removed the workspace presentation, but the git worktree " + f"still exists: {worktree.worktree_path}" + ) from e + raise WorktreeRemovalError( + f"Herdr failed to remove the worktree at {worktree.worktree_path}: {e}" + ) from e + + def _executable(self) -> str: + executable = herdr.resolve_executable(self._explicit_executable) + if executable is None: + raise ProviderUnavailableError( + "herdr is not available: set HERDR_BIN_PATH or install herdr on PATH" + ) + return executable + + def _managed_entries( + self, repository_path: Path, result: dict + ) -> builtins.list[ManagedWorktree]: + return [ + self._managed( + repository_path, + Path(entry["path"]), + entry["branch"], + workspace_id=entry.get("open_workspace_id"), + ) + for entry in result.get("worktrees", []) + if entry.get("is_linked_worktree") and "branch" in entry + ] + + def _managed( + self, + repository_path: Path, + worktree_path: Path, + branch: str, + *, + workspace_id: str | None, + ) -> ManagedWorktree: + metadata = {"workspace_id": workspace_id} if workspace_id else {} + return ManagedWorktree( + repository_path=repository_path, + worktree_path=worktree_path, + branch=branch, + provider_kind=ProviderKind.HERDR, + provider_id=workspace_id, + metadata=metadata, + ) diff --git a/src/git_workspace/providers/native_git.py b/src/git_workspace/providers/native_git.py new file mode 100644 index 0000000..9965ebe --- /dev/null +++ b/src/git_workspace/providers/native_git.py @@ -0,0 +1,138 @@ +import builtins +import logging +import shutil +from pathlib import Path + +from git_workspace.errors import ( + GitFetchError, + WorktreeAlreadyExistsError, + WorktreeCreationError, + WorktreeNotFoundError, +) +from git_workspace.subprocesses import git +from git_workspace.subprocesses.runner import DEFAULT_RUNNER, CommandRunner +from git_workspace.workspace.models import ManagedWorktree, ProviderKind, WorktreeRequest + +logger = logging.getLogger(__name__) + + +class NativeGitProvider: + """ + Worktree provider backed by the git CLI. + + Owns the full worktree-creation resolution chain: local branch → remote + branch (with tracking) → new branch from base (preferring origin/) + → orphan fallback for empty repositories. + """ + + def __init__(self, runner: CommandRunner = DEFAULT_RUNNER) -> None: + self._runner = runner + + @property + def kind(self) -> ProviderKind: + return ProviderKind.NATIVE_GIT + + def is_available(self) -> bool: + return shutil.which("git") is not None + + def create(self, request: WorktreeRequest) -> ManagedWorktree: + repository_path = request.repository_path + branch = request.branch + target = request.target_path or (repository_path / branch).resolve() + + if any(wt.worktree_path == target for wt in self.list(repository_path)): + raise WorktreeAlreadyExistsError(f"A worktree already exists at {target}") + + if git.local_branch_exists(branch, cwd=repository_path, runner=self._runner): + logger.info("creating worktree from local branch %r", branch) + git.create_worktree_from_local_branch( + target, branch, cwd=repository_path, runner=self._runner + ) + return self._managed(repository_path, target, branch) + + fetched = True + try: + git.fetch_origin(cwd=repository_path, runner=self._runner) + except GitFetchError: + logger.debug("fetch failed, proceeding with local refs only") + fetched = False + + if fetched and git.remote_branch_exists(branch, cwd=repository_path, runner=self._runner): + logger.info("creating worktree from remote branch %r", branch) + git.create_worktree_from_remote_branch( + target, branch, cwd=repository_path, runner=self._runner + ) + return self._managed(repository_path, target, branch) + + if not request.create_branch: + raise WorktreeCreationError( + f"Branch {branch!r} does not exist and branch creation was not requested" + ) + + base_branch = request.base_branch + if base_branch is None: + raise WorktreeCreationError(f"A base branch is required to create branch {branch!r}") + + # Prefer origin/ so we always fork from the latest remote commit, + # not a local ref that may be stale or locked by an active worktree. + base_ref = ( + f"origin/{base_branch}" + if git.remote_branch_exists(base_branch, cwd=repository_path, runner=self._runner) + else base_branch + ) + + logger.info("creating new worktree for branch %r from base %r", branch, base_ref) + git.create_worktree_new(target, branch, base_ref, cwd=repository_path, runner=self._runner) + return self._managed(repository_path, target, branch) + + def import_existing(self, worktree_path: Path) -> ManagedWorktree: + worktree = self.find(worktree_path) + if worktree is None: + raise WorktreeNotFoundError( + f"No registered git worktree found at {worktree_path.expanduser().resolve()}" + ) + return worktree + + def find(self, worktree_path: Path) -> ManagedWorktree | None: + canonical = worktree_path.expanduser().resolve() + if not canonical.is_dir(): + return None + + common_dir = git.git_common_dir(canonical, runner=self._runner) + if common_dir is None: + return None + + repository_path = common_dir.parent + return next( + (wt for wt in self.list(repository_path) if wt.worktree_path == canonical), + None, + ) + + def list(self, repository_path: Path | None = None) -> builtins.list[ManagedWorktree]: + if repository_path is None: + raise WorktreeNotFoundError( + "The native git provider has no global worktree registry; " + "a repository path is required to list worktrees" + ) + + raw_worktrees = git.list_worktrees(cwd=repository_path, runner=self._runner) + return [ + self._managed(repository_path, Path(raw["directory"]), raw["branch"]) + for raw in raw_worktrees + ] + + def remove(self, worktree: ManagedWorktree, *, force: bool = False) -> None: + git.remove_worktree( + worktree.worktree_path, + force, + cwd=worktree.repository_path, + runner=self._runner, + ) + + def _managed(self, repository_path: Path, worktree_path: Path, branch: str) -> ManagedWorktree: + return ManagedWorktree( + repository_path=repository_path, + worktree_path=worktree_path, + branch=branch, + provider_kind=ProviderKind.NATIVE_GIT, + ) diff --git a/src/git_workspace/subprocesses/__init__.py b/src/git_workspace/subprocesses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/git_workspace/git.py b/src/git_workspace/subprocesses/git.py similarity index 67% rename from src/git_workspace/git.py rename to src/git_workspace/subprocesses/git.py index 3a86f95..7acbcf3 100644 --- a/src/git_workspace/git.py +++ b/src/git_workspace/subprocesses/git.py @@ -1,6 +1,5 @@ import logging import re -import subprocess from pathlib import Path from git_workspace.errors import ( @@ -11,6 +10,7 @@ WorktreeListingError, WorktreeRemovalError, ) +from git_workspace.subprocesses.runner import DEFAULT_RUNNER, CommandRunner logger = logging.getLogger(__name__) @@ -26,6 +26,8 @@ def clone( target: Path | None = None, branch: str | None = None, bare: bool = False, + *, + runner: CommandRunner = DEFAULT_RUNNER, ) -> None: """ Clones a git repository @@ -51,13 +53,13 @@ def clone( cmd.append(target) logger.debug("cloning %r -> %s", url, target or "(inferred)") - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: + result = runner.run(cmd, check=False) + if not result.ok: logger.error("git clone failed for %r: %s", url, result.stderr.strip()) raise GitCloneError(f"Failed to clone {url!r}") -def init(target: Path, bare: bool) -> None: +def init(target: Path, bare: bool, *, runner: CommandRunner = DEFAULT_RUNNER) -> None: """ Initializes a git repository at the provided target @@ -74,17 +76,16 @@ def init(target: Path, bare: bool) -> None: cmd.append(target) logger.debug("initializing %s repo at %s", "bare" if bare else "non-bare", target) - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: + result = runner.run(cmd, check=False) + if not result.ok: logger.error("git init failed at %s: %s", target, result.stderr.strip()) raise GitInitError(f"Failed to init repository at {target!r}: {result.stderr.strip()}") -def list_worktrees(cwd: Path) -> list[dict[str, str]]: +def list_worktrees(cwd: Path, *, runner: CommandRunner = DEFAULT_RUNNER) -> list[dict[str, str]]: logger.debug("listing worktrees in %s", cwd) - cmd = ["git", "worktree", "list", "--porcelain"] - result = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd) - if result.returncode != 0: + result = runner.run(["git", "worktree", "list", "--porcelain"], cwd=cwd, check=False) + if not result.ok: logger.error("git worktree list failed in %s: %s", cwd, result.stderr.strip()) raise WorktreeListingError(f"Failed to list worktrees in {cwd!r}: {result.stderr.strip()}") @@ -97,7 +98,7 @@ def list_worktrees(cwd: Path) -> list[dict[str, str]]: return worktrees -def configure_remote_fetch_refspec(cwd: Path) -> None: +def configure_remote_fetch_refspec(cwd: Path, *, runner: CommandRunner = DEFAULT_RUNNER) -> None: """ Sets the remote.origin.fetch refspec to use remote-tracking refs. @@ -105,29 +106,27 @@ def configure_remote_fetch_refspec(cwd: Path) -> None: refs/remotes/origin/* and remote-branch lookups always fail. This sets it to '+refs/heads/*:refs/remotes/origin/*' (identical to a normal clone). """ - subprocess.run( + runner.run( ["git", "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"], cwd=cwd, - capture_output=True, - text=True, + check=False, ) -def fetch_origin(cwd: Path) -> None: +def fetch_origin(cwd: Path, *, runner: CommandRunner = DEFAULT_RUNNER) -> None: """ Fetches from origin and prunes stale remote-tracking branches. :raises GitFetchError: If the fetch fails """ logger.debug("fetching origin in %s", cwd) - cmd = ["git", "fetch", "origin", "--prune"] - result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) - if result.returncode != 0: + result = runner.run(["git", "fetch", "origin", "--prune"], cwd=cwd, check=False) + if not result.ok: logger.warning("git fetch failed in %s: %s", cwd, result.stderr.strip()) raise GitFetchError(f"Failed to fetch from origin: {result.stderr.strip()}") -def local_branch_exists(branch: str, cwd: Path) -> bool: +def local_branch_exists(branch: str, cwd: Path, *, runner: CommandRunner = DEFAULT_RUNNER) -> bool: """ Returns whether a local branch exists @@ -136,13 +135,13 @@ def local_branch_exists(branch: str, cwd: Path) -> bool: :returns: True if the branch exists locally, False otherwise """ cmd = ["git", "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"] - result = subprocess.run(cmd, cwd=cwd, capture_output=True) - exists = result.returncode == 0 + result = runner.run(cmd, cwd=cwd, check=False) + exists = result.ok logger.debug("local branch %r exists: %s", branch, exists) return exists -def remote_branch_exists(branch: str, cwd: Path) -> bool: +def remote_branch_exists(branch: str, cwd: Path, *, runner: CommandRunner = DEFAULT_RUNNER) -> bool: """ Returns whether a branch exists on origin @@ -151,13 +150,13 @@ def remote_branch_exists(branch: str, cwd: Path) -> bool: :returns: True if the branch exists on origin, False otherwise """ cmd = ["git", "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}"] - result = subprocess.run(cmd, cwd=cwd, capture_output=True) - exists = result.returncode == 0 + result = runner.run(cmd, cwd=cwd, check=False) + exists = result.ok logger.debug("remote branch %r exists: %s", branch, exists) return exists -def skip_worktree(path: Path) -> None: +def skip_worktree(path: Path, *, runner: CommandRunner = DEFAULT_RUNNER) -> None: """ Marks a file with git update-index --skip-worktree so local changes are ignored @@ -167,14 +166,16 @@ def skip_worktree(path: Path) -> None: :param path: The file path to mark """ logger.debug("marking %s as skip-worktree", path) - subprocess.run( - ["git", "update-index", "--skip-worktree", path], - capture_output=True, - text=True, - ) + runner.run(["git", "update-index", "--skip-worktree", path], check=False) -def create_worktree_from_local_branch(worktree_dir: Path, branch: str, cwd: Path) -> None: +def create_worktree_from_local_branch( + worktree_dir: Path, + branch: str, + cwd: Path, + *, + runner: CommandRunner = DEFAULT_RUNNER, +) -> None: """ Creates a worktree for an existing local branch @@ -184,9 +185,8 @@ def create_worktree_from_local_branch(worktree_dir: Path, branch: str, cwd: Path :raises WorktreeCreationError: If the worktree cannot be created """ logger.debug("creating worktree for local branch %r at %s", branch, worktree_dir) - cmd = ["git", "worktree", "add", worktree_dir, branch] - result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) - if result.returncode != 0: + result = runner.run(["git", "worktree", "add", worktree_dir, branch], cwd=cwd, check=False) + if not result.ok: logger.error( "failed to create worktree for local branch %r: %s", branch, result.stderr.strip() ) @@ -195,7 +195,13 @@ def create_worktree_from_local_branch(worktree_dir: Path, branch: str, cwd: Path ) -def create_worktree_from_remote_branch(worktree_dir: Path, branch: str, cwd: Path) -> None: +def create_worktree_from_remote_branch( + worktree_dir: Path, + branch: str, + cwd: Path, + *, + runner: CommandRunner = DEFAULT_RUNNER, +) -> None: """ Creates a worktree with a new local branch tracking origin/ @@ -205,7 +211,7 @@ def create_worktree_from_remote_branch(worktree_dir: Path, branch: str, cwd: Pat :raises WorktreeCreationError: If the worktree cannot be created """ logger.debug("creating worktree tracking remote branch %r at %s", branch, worktree_dir) - cmd = [ + cmd: list[str | Path] = [ "git", "worktree", "add", @@ -215,8 +221,8 @@ def create_worktree_from_remote_branch(worktree_dir: Path, branch: str, cwd: Pat worktree_dir, f"origin/{branch}", ] - result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) - if result.returncode != 0: + result = runner.run(cmd, cwd=cwd, check=False) + if not result.ok: logger.error( "failed to create worktree for remote branch %r: %s", branch, result.stderr.strip() ) @@ -230,6 +236,8 @@ def create_worktree_new( branch: str, base_branch: str, cwd: Path, + *, + runner: CommandRunner = DEFAULT_RUNNER, ) -> None: """ Creates a worktree with a brand new local branch from a base branch. @@ -246,16 +254,24 @@ def create_worktree_new( logger.debug( "creating new worktree for branch %r from %r at %s", branch, base_branch, worktree_dir ) - cmd = ["git", "worktree", "add", "-b", branch, worktree_dir, base_branch] - result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) - if result.returncode != 0: + cmd: list[str | Path] = ["git", "worktree", "add", "-b", branch, worktree_dir, base_branch] + result = runner.run(cmd, cwd=cwd, check=False) + if not result.ok: # Base ref doesn't exist — repo has no commits yet. Create an orphan worktree. logger.debug( "base branch %r not found, falling back to orphan worktree for %r", base_branch, branch ) - orphan_cmd = ["git", "worktree", "add", "--orphan", "-b", branch, worktree_dir] - orphan_result = subprocess.run(orphan_cmd, cwd=cwd, capture_output=True, text=True) - if orphan_result.returncode != 0: + orphan_cmd: list[str | Path] = [ + "git", + "worktree", + "add", + "--orphan", + "-b", + branch, + worktree_dir, + ] + orphan_result = runner.run(orphan_cmd, cwd=cwd, check=False) + if not orphan_result.ok: logger.error( "failed to create orphan worktree for branch %r: %s", branch, @@ -266,42 +282,58 @@ def create_worktree_new( ) -def try_get_worktree_dir() -> str | None: - cmd = ["git", "rev-parse", "--show-toplevel"] - result = subprocess.run( - cmd, - capture_output=True, - text=True, - ) - worktree_dir = result.stdout.strip() if result.returncode == 0 else None +def git_common_dir(path: Path, *, runner: CommandRunner = DEFAULT_RUNNER) -> Path | None: + """ + Returns the main repository's git directory for a worktree path. + + For a linked worktree this resolves to the main repo's git dir (not the + per-worktree admin dir). The output may be relative, so it is resolved + against ``path``. + + :param path: A directory inside a worktree. + :returns: The resolved common git directory, or None when ``path`` is not + inside a git repository. + """ + result = runner.run(["git", "rev-parse", "--git-common-dir"], cwd=path, check=False) + if not result.ok: + logger.debug("no git common dir for %s", path) + return None + common_dir = (path / result.stdout.strip()).resolve() + logger.debug("git common dir for %s: %s", path, common_dir) + return common_dir + + +def try_get_worktree_dir(*, runner: CommandRunner = DEFAULT_RUNNER) -> str | None: + result = runner.run(["git", "rev-parse", "--show-toplevel"], check=False) + worktree_dir = result.stdout.strip() if result.ok else None logger.debug("cwd worktree dir: %s", worktree_dir or "(none)") return worktree_dir -def get_worktree_branch(cwd: str) -> str: - cmd = ["git", "branch", "--show-current"] - result = subprocess.run( - cmd, - capture_output=True, - text=True, - cwd=cwd, - ) +def get_worktree_branch(cwd: str, *, runner: CommandRunner = DEFAULT_RUNNER) -> str: + result = runner.run(["git", "branch", "--show-current"], cwd=Path(cwd), check=False) branch = result.stdout.strip() logger.debug("current branch in %s: %r", cwd, branch) return branch -def prune_worktrees(cwd: Path) -> None: +def prune_worktrees(cwd: Path, *, runner: CommandRunner = DEFAULT_RUNNER) -> None: """ Removes stale worktree administrative files via `git worktree prune`. :param cwd: The git repository directory. """ logger.debug("pruning stale worktrees in %s", cwd) - subprocess.run(["git", "worktree", "prune"], cwd=cwd, capture_output=True, text=True) + runner.run(["git", "worktree", "prune"], cwd=cwd, check=False) -def remove_worktree(worktree_dir: Path, force: bool = False, *, cwd: Path) -> None: +def remove_worktree( + worktree_dir: Path, + force: bool = False, + *, + cwd: Path, + runner: CommandRunner = DEFAULT_RUNNER, +) -> None: """ Removes a git worktree without deleting the branch. @@ -318,8 +350,8 @@ def remove_worktree(worktree_dir: Path, force: bool = False, *, cwd: Path) -> No cmd.append(worktree_dir) - result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) - if result.returncode != 0: + result = runner.run(cmd, cwd=cwd, check=False) + if not result.ok: logger.error("failed to remove worktree at %s: %s", worktree_dir, result.stderr.strip()) raise WorktreeRemovalError( f"Failed to remove worktree at {worktree_dir!r}: {result.stderr.strip()}" diff --git a/src/git_workspace/subprocesses/herdr.py b/src/git_workspace/subprocesses/herdr.py new file mode 100644 index 0000000..f0b817e --- /dev/null +++ b/src/git_workspace/subprocesses/herdr.py @@ -0,0 +1,198 @@ +""" +Thin wrappers around the herdr CLI's socket-API subcommands. + +All commands use herdr's JSON output. Herdr reports failures as an +``{"error": {"code", "message"}}`` envelope — sometimes with exit code 0 — +so parsing always inspects the payload rather than trusting the exit code. +""" + +import json +import logging +import os +import shutil +from pathlib import Path + +from git_workspace.errors import HerdrError +from git_workspace.subprocesses.runner import DEFAULT_RUNNER, CommandRunner + +logger = logging.getLogger(__name__) + +BIN_PATH_ENV = "HERDR_BIN_PATH" +CONTEXT_ENV = "HERDR_ENV" +SOCKET_PATH_ENV = "HERDR_SOCKET_PATH" + + +def resolve_executable(explicit: str | None = None) -> str | None: + """ + Resolves the herdr executable: explicit value → HERDR_BIN_PATH → PATH. + + :returns: The executable to invoke, or None when herdr is not available. + """ + if explicit: + return explicit + env_path = os.environ.get(BIN_PATH_ENV) + if env_path: + return env_path + return shutil.which("herdr") + + +def in_verified_context() -> bool: + """ + Returns whether the current process runs inside a reachable herdr session. + + True only when herdr's environment marker is set and the advertised + session socket actually exists — the executable merely being installed is + not enough to consider herdr active. + """ + if os.environ.get(CONTEXT_ENV) != "1": + return False + socket_path = os.environ.get(SOCKET_PATH_ENV) + return bool(socket_path) and Path(socket_path).exists() + + +def list_worktrees( + cwd: Path, + *, + executable: str, + runner: CommandRunner = DEFAULT_RUNNER, +) -> dict: + """ + Lists the repository's worktrees as seen by herdr. + + :returns: The result payload: ``{"source": {...}, "worktrees": [...]}``. + Worktree entries carry ``open_workspace_id`` when presented in herdr. + """ + return _invoke( + [executable, "worktree", "list", "--cwd", cwd, "--json"], + runner=runner, + ) + + +def create_worktree( + repository_path: Path, + branch: str, + target_path: Path, + base_branch: str | None, + *, + executable: str, + runner: CommandRunner = DEFAULT_RUNNER, +) -> dict: + """ + Creates a git worktree through herdr. + + Herdr opens a workspace presentation as a side effect; ``--no-focus`` + keeps it in the background so focusing stays a presenter concern. + + :returns: The ``worktree_created`` result payload. + """ + cmd: list[str | Path] = [ + executable, + "worktree", + "create", + "--cwd", + repository_path, + "--branch", + branch, + "--path", + target_path, + ] + if base_branch: + cmd.extend(["--base", base_branch]) + cmd.extend(["--no-focus", "--json"]) + return _invoke(cmd, runner=runner) + + +def open_worktree( + repository_path: Path, + worktree_path: Path, + *, + executable: str, + runner: CommandRunner = DEFAULT_RUNNER, +) -> dict: + """ + Opens (or returns) the herdr workspace presenting an existing worktree. + + Natively idempotent: the payload carries ``already_open``. + + :returns: The ``worktree_opened`` result payload. + """ + return _invoke( + [ + executable, + "worktree", + "open", + "--cwd", + repository_path, + "--path", + worktree_path, + "--no-focus", + "--json", + ], + runner=runner, + ) + + +def remove_worktree( + workspace_id: str, + *, + force: bool = False, + executable: str, + runner: CommandRunner = DEFAULT_RUNNER, +) -> dict: + """ + Removes the git worktree presented by the given herdr workspace. + + Herdr identifies worktrees by their open workspace, closes the workspace, + and removes the worktree. The branch is preserved. + + :returns: The ``worktree_removed`` result payload. + """ + cmd: list[str | Path] = [executable, "worktree", "remove", "--workspace", workspace_id] + if force: + cmd.append("--force") + cmd.append("--json") + return _invoke(cmd, runner=runner) + + +def focus_workspace( + workspace_id: str, + *, + executable: str, + runner: CommandRunner = DEFAULT_RUNNER, +) -> dict: + """Focuses a herdr workspace.""" + return _invoke([executable, "workspace", "focus", workspace_id], runner=runner) + + +def close_workspace( + workspace_id: str, + *, + executable: str, + runner: CommandRunner = DEFAULT_RUNNER, +) -> dict: + """Closes a herdr workspace, preserving the underlying git worktree.""" + return _invoke([executable, "workspace", "close", workspace_id], runner=runner) + + +def _invoke(cmd: list[str | Path], *, runner: CommandRunner) -> dict: + result = runner.run(cmd, check=False) + + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError: + detail = result.stderr.strip() or result.stdout.strip() or f"exit code {result.exit_code}" + raise HerdrError(f"herdr returned malformed output: {detail}") from None + + error = payload.get("error") + if error is not None: + raise HerdrError( + error.get("message", "herdr command failed"), + code=error.get("code"), + ) + + if not result.ok: + raise HerdrError( + f"herdr command failed with exit code {result.exit_code}: {result.stderr.strip()}" + ) + + return payload.get("result", {}) diff --git a/src/git_workspace/subprocesses/runner.py b/src/git_workspace/subprocesses/runner.py new file mode 100644 index 0000000..c5a89f3 --- /dev/null +++ b/src/git_workspace/subprocesses/runner.py @@ -0,0 +1,84 @@ +import logging +import subprocess +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol, runtime_checkable + +from git_workspace.errors import ExternalCommandError + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CommandResult: + """The captured outcome of an external command invocation.""" + + args: tuple[str, ...] + exit_code: int + stdout: str + stderr: str + + @property + def ok(self) -> bool: + return self.exit_code == 0 + + +@runtime_checkable +class CommandRunner(Protocol): + """ + Executes external commands and captures their output. + + All structured tool invocations (git, and future provider/presenter + executables) must go through a runner so tests can inject fakes. The one + sanctioned exception is hook execution, which runs user-authored shell + command strings and therefore cannot use argv lists. + """ + + def run( + self, + args: Sequence[str | Path], + *, + cwd: Path | None = None, + env: Mapping[str, str] | None = None, + check: bool = True, + ) -> CommandResult: ... + + +class SubprocessCommandRunner: + """Runs commands via subprocess with captured output. Never uses shell=True.""" + + def run( + self, + args: Sequence[str | Path], + *, + cwd: Path | None = None, + env: Mapping[str, str] | None = None, + check: bool = True, + ) -> CommandResult: + str_args = tuple(str(arg) for arg in args) + logger.debug("running command: %s (cwd=%s)", " ".join(str_args), cwd or "(inherited)") + completed = subprocess.run( + str_args, + cwd=cwd, + env=dict(env) if env is not None else None, + capture_output=True, + text=True, + ) + result = CommandResult( + args=str_args, + exit_code=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + if check and not result.ok: + raise ExternalCommandError( + command=list(str_args), + exit_code=result.exit_code, + stdout=result.stdout, + stderr=result.stderr, + ) + return result + + +DEFAULT_RUNNER: CommandRunner = SubprocessCommandRunner() diff --git a/src/git_workspace/workspace/__init__.py b/src/git_workspace/workspace/__init__.py new file mode 100644 index 0000000..1d44e93 --- /dev/null +++ b/src/git_workspace/workspace/__init__.py @@ -0,0 +1,15 @@ +from git_workspace.workspace.core import ( + Workspace, + WorkspaceFactory, + WorkspacePaths, + WorkspaceResolver, + WorkspaceValidator, +) + +__all__ = [ + "Workspace", + "WorkspaceFactory", + "WorkspacePaths", + "WorkspaceResolver", + "WorkspaceValidator", +] diff --git a/src/git_workspace/assets.py b/src/git_workspace/workspace/assets.py similarity index 99% rename from src/git_workspace/assets.py rename to src/git_workspace/workspace/assets.py index 8ef25e2..b9328eb 100644 --- a/src/git_workspace/assets.py +++ b/src/git_workspace/workspace/assets.py @@ -7,11 +7,11 @@ from jinja2 import DebugUndefined, Environment, TemplateError -from git_workspace import git from git_workspace.errors import WorkspaceCopyError, WorkspaceLinkError from git_workspace.manifest import Asset, Copy, Link +from git_workspace.subprocesses import git from git_workspace.ui import console -from git_workspace.worktree import Worktree +from git_workspace.workspace.worktree import Worktree logger = logging.getLogger(__name__) diff --git a/src/git_workspace/workspace.py b/src/git_workspace/workspace/core.py similarity index 87% rename from src/git_workspace/workspace.py rename to src/git_workspace/workspace/core.py index d6fe957..1466186 100644 --- a/src/git_workspace/workspace.py +++ b/src/git_workspace/workspace/core.py @@ -2,7 +2,7 @@ import shutil from pathlib import Path -from git_workspace import git, utils +from git_workspace import utils from git_workspace.errors import ( GitCloneError, GitInitError, @@ -11,7 +11,9 @@ WorkspaceCreationError, ) from git_workspace.manifest import Manifest -from git_workspace.worktree import Worktree +from git_workspace.providers.native_git import NativeGitProvider +from git_workspace.subprocesses import git +from git_workspace.workspace.worktree import Worktree logger = logging.getLogger(__name__) @@ -53,6 +55,11 @@ def manifest(self) -> Path: def cache(self) -> Path: return self.config / ".cache" + # /.workspace/.state + @property + def state(self) -> Path: + return self.config / ".state" + # /.git @property def git(self) -> Path: @@ -132,6 +139,39 @@ def _resolve(cls, path: Path) -> Path: logger.warning("could not resolve workspace root from: %s", path) raise UnableToResolveWorkspaceError(f"Unable to resolve workspace root path from: {path!r}") + @classmethod + def resolve_from_worktree(cls, worktree_path: Path) -> Workspace: + """ + Resolves the workspace owning a worktree at an arbitrary path. + + The ``.workspace`` config directory lives as a sibling of the main git + directory, so the worktree's ``git rev-parse --git-common-dir`` leads + back to the workspace root regardless of where the worktree itself was + created (bare layout: ``/.git`` + ``/.workspace``; normal + repo: ``/.git`` + ``/.workspace``). + + Falls back to walking up from the path when git-based discovery does + not land on a valid workspace (e.g. when invoked from inside + ``.workspace``, which is itself a git repository). + + :param worktree_path: A directory inside the worktree to resolve for. + :raises UnableToResolveWorkspaceError: If no workspace root can be found. + :returns: The resolved ``Workspace``. + """ + canonical = worktree_path.expanduser().resolve() + + common_dir = git.git_common_dir(canonical) + if common_dir is not None: + candidate = common_dir.parent + try: + WorkspaceValidator.validate(candidate) + logger.info("resolved workspace root via git common dir: %s", candidate) + return Workspace(candidate) + except InvalidWorkspaceError: + logger.debug("git common dir parent %s is not a workspace root", candidate) + + return Workspace(cls._resolve(canonical)) + @classmethod def resolve(cls, raw_workspace_dir: str | None) -> Workspace: """ @@ -281,6 +321,11 @@ def __init__(self, dir: Path) -> None: self.paths = WorkspacePaths(dir) self.manifest = Manifest.load(self) + @property + def provider(self) -> NativeGitProvider: + """The worktree provider owning this workspace's git worktree lifecycle.""" + return NativeGitProvider() + @classmethod def resolve(cls, workspace_dir: str | None) -> Workspace: """ @@ -357,17 +402,3 @@ def resolve_worktree(self, branch: str | None) -> Worktree: :raises WorktreeResolutionError: If no worktree can be resolved. """ return Worktree.resolve(self, branch) - - def resolve_or_create_worktree(self, branch: str | None, base_branch: str | None) -> Worktree: - """ - Resolves an existing worktree or creates a new one for the given branch. - - :param branch: Branch name to resolve or create, or ``None`` to infer from cwd. - :param base_branch: Branch to base a new branch on. Falls back to the - manifest's ``base_branch`` when ``None``. - :returns: The resolved or newly created ``Worktree`` instance. - :raises WorktreeResolutionError: If ``branch`` is ``None`` and the cwd is - not inside a known worktree. - :raises WorktreeCreationError: If worktree creation fails at the git level. - """ - return Worktree.resolve_or_create(self, branch, base_branch) diff --git a/src/git_workspace/env.py b/src/git_workspace/workspace/env.py similarity index 98% rename from src/git_workspace/env.py rename to src/git_workspace/workspace/env.py index b7c8fa2..15d90c5 100644 --- a/src/git_workspace/env.py +++ b/src/git_workspace/workspace/env.py @@ -6,7 +6,7 @@ from git_workspace.utils import normalize_variable_name if TYPE_CHECKING: - from git_workspace.worktree import Worktree + from git_workspace.workspace.worktree import Worktree _BASE_VAR_BUILDERS: tuple[tuple[str, Callable[[Worktree], str]], ...] = ( ("GIT_WORKSPACE_BRANCH", lambda wt: wt.branch), diff --git a/src/git_workspace/fingerprint.py b/src/git_workspace/workspace/fingerprint.py similarity index 97% rename from src/git_workspace/fingerprint.py rename to src/git_workspace/workspace/fingerprint.py index effe84f..d295a2c 100644 --- a/src/git_workspace/fingerprint.py +++ b/src/git_workspace/workspace/fingerprint.py @@ -5,7 +5,7 @@ from git_workspace.manifest import Fingerprint if TYPE_CHECKING: - from git_workspace.worktree import Worktree + from git_workspace.workspace.worktree import Worktree logger = logging.getLogger(__name__) diff --git a/src/git_workspace/hooks.py b/src/git_workspace/workspace/hooks.py similarity index 99% rename from src/git_workspace/hooks.py rename to src/git_workspace/workspace/hooks.py index 946fe6b..93171e4 100644 --- a/src/git_workspace/hooks.py +++ b/src/git_workspace/workspace/hooks.py @@ -9,7 +9,7 @@ from git_workspace.errors import HookExecutionError from git_workspace.manifest import HookGroup from git_workspace.ui import HookProgress, console -from git_workspace.worktree import Worktree +from git_workspace.workspace.worktree import Worktree logger = logging.getLogger(__name__) diff --git a/src/git_workspace/workspace/lock.py b/src/git_workspace/workspace/lock.py new file mode 100644 index 0000000..83a374a --- /dev/null +++ b/src/git_workspace/workspace/lock.py @@ -0,0 +1,43 @@ +import fcntl +import logging +import os +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +from git_workspace.errors import WorkspaceLockedError +from git_workspace.workspace.state import state_file_stem + +logger = logging.getLogger(__name__) + + +@contextmanager +def workspace_operation_lock(locks_dir: Path, worktree_path: Path) -> Iterator[None]: + """ + Holds an exclusive, non-blocking lifecycle lock for a canonical worktree + path while a mutating operation (up/prepare/down/rm/prune) runs. + + A second caller fails fast instead of waiting. Lock files are never + deleted: unlink-then-flock is racy, and empty lock files are harmless. + + :raises WorkspaceLockedError: If another operation holds the lock. + """ + locks_dir.mkdir(parents=True, exist_ok=True) + lock_path = locks_dir / f"{state_file_stem(worktree_path)}.lock" + + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + os.close(fd) + raise WorkspaceLockedError( + f"Another workspace operation is already running for:\n{worktree_path}" + ) from None + + logger.debug("acquired workspace lock: %s", lock_path) + try: + yield + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + logger.debug("released workspace lock: %s", lock_path) diff --git a/src/git_workspace/workspace/models.py b/src/git_workspace/workspace/models.py new file mode 100644 index 0000000..a0b7416 --- /dev/null +++ b/src/git_workspace/workspace/models.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path + + +class ProviderKind(StrEnum): + NATIVE_GIT = "native-git" + HERDR = "herdr" + + +class PresenterKind(StrEnum): + NONE = "none" + HERDR = "herdr" + + +class WorkspaceLifecycleState(StrEnum): + CREATED = "created" + PREPARING = "preparing" + READY = "ready" + PREPARATION_FAILED = "preparation-failed" + DETACHED = "detached" + TEARING_DOWN = "tearing-down" + REMOVED = "removed" + + +def _canonical(path: Path) -> Path: + return path.expanduser().resolve() + + +@dataclass(frozen=True) +class WorktreeRequest: + repository_path: Path + branch: str + base_branch: str | None = None + target_path: Path | None = None + create_branch: bool = True + + def __post_init__(self) -> None: + object.__setattr__(self, "repository_path", _canonical(self.repository_path)) + if self.target_path is not None: + object.__setattr__(self, "target_path", _canonical(self.target_path)) + + +@dataclass(frozen=True) +class ManagedWorktree: + repository_path: Path + worktree_path: Path + branch: str + provider_kind: ProviderKind + provider_id: str | None = None + metadata: Mapping[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "repository_path", _canonical(self.repository_path)) + object.__setattr__(self, "worktree_path", _canonical(self.worktree_path)) + + +@dataclass(frozen=True) +class Presentation: + presenter_kind: PresenterKind + presentation_id: str | None = None + metadata: Mapping[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class PresenterCapabilities: + can_find_existing: bool + can_focus_existing: bool + can_close: bool + can_list: bool + supports_runtime_identity: bool + + +@dataclass(frozen=True) +class WorkspaceRecord: + worktree: ManagedWorktree + presentation: Presentation | None + lifecycle_state: WorkspaceLifecycleState + preparation_error: str | None = None diff --git a/src/git_workspace/workspace/preparer.py b/src/git_workspace/workspace/preparer.py new file mode 100644 index 0000000..56e72c0 --- /dev/null +++ b/src/git_workspace/workspace/preparer.py @@ -0,0 +1,103 @@ +import logging +from pathlib import Path + +from git_workspace.workspace.assets import Copier, IgnoreManager, Linker +from git_workspace.workspace.core import Workspace +from git_workspace.workspace.env import build_env +from git_workspace.workspace.fingerprint import compute_fingerprints +from git_workspace.workspace.hooks import HookRunner +from git_workspace.workspace.worktree import Worktree + +logger = logging.getLogger(__name__) + + +class WorkspacePreparer: + """ + Owns the project-specific setup lifecycle of a worktree: assets (links, + copies, templates), environment construction, fingerprints, and hooks. + + Backend-agnostic by design: it receives plain data (a worktree path and + branch) and must never import providers, presenters, or backends. Hook + execution intentionally runs user-authored shell command strings (see + HookRunner) and is exempt from the CommandRunner argv-only rule. + """ + + def __init__(self, workspace: Workspace) -> None: + self._workspace = workspace + + def prepare( + self, + worktree_path: Path, + branch: str, + *, + runtime_vars: dict[str, str] | None = None, + effective_branch: str | None = None, + ) -> None: + """ + Applies assets and runs ``on_setup`` hooks for the worktree. + + Safe to retry: asset application honors overwrite/override flags and + hooks are expected to self-skip via fingerprints and the cache. + """ + logger.debug("preparing worktree %r at %s", branch, worktree_path) + worktree, env = self._context(worktree_path, branch, runtime_vars) + + with IgnoreManager(worktree) as ignore: + Copier(worktree, ignore, env).apply() + Linker(worktree, ignore).apply() + + with HookRunner(worktree, env=env, effective_branch=effective_branch or branch) as runner: + runner.run_on_setup_hooks() + + def attach( + self, + worktree_path: Path, + branch: str, + *, + runtime_vars: dict[str, str] | None = None, + effective_branch: str | None = None, + ) -> None: + """Runs ``on_attach`` hooks when entering a worktree session.""" + logger.debug("attaching to worktree %r at %s", branch, worktree_path) + worktree, env = self._context(worktree_path, branch, runtime_vars) + with HookRunner(worktree, env=env, effective_branch=effective_branch or branch) as runner: + runner.run_on_attach_hooks() + + def detach( + self, + worktree_path: Path, + branch: str, + *, + runtime_vars: dict[str, str] | None = None, + effective_branch: str | None = None, + ) -> None: + """Runs ``on_detach`` hooks when leaving a worktree session.""" + logger.debug("detaching from worktree %r at %s", branch, worktree_path) + worktree, env = self._context(worktree_path, branch, runtime_vars) + with HookRunner(worktree, env=env, effective_branch=effective_branch or branch) as runner: + runner.run_on_detach_hooks() + + def teardown( + self, + worktree_path: Path, + branch: str, + *, + runtime_vars: dict[str, str] | None = None, + effective_branch: str | None = None, + ) -> None: + """Runs ``on_teardown`` hooks before a worktree is removed.""" + logger.debug("tearing down worktree %r at %s", branch, worktree_path) + worktree, env = self._context(worktree_path, branch, runtime_vars) + with HookRunner(worktree, env=env, effective_branch=effective_branch or branch) as runner: + runner.run_on_teardown_hooks() + + def _context( + self, + worktree_path: Path, + branch: str, + runtime_vars: dict[str, str] | None, + ) -> tuple[Worktree, dict[str, str]]: + worktree = Worktree(workspace=self._workspace, dir=worktree_path, branch=branch) + fingerprint_vars = compute_fingerprints(worktree, self._workspace.manifest.fingerprints) + env = build_env(worktree, runtime_vars or {}, fingerprint_vars) + return worktree, env diff --git a/src/git_workspace/workspace/service.py b/src/git_workspace/workspace/service.py new file mode 100644 index 0000000..309e4e8 --- /dev/null +++ b/src/git_workspace/workspace/service.py @@ -0,0 +1,477 @@ +import builtins +import logging +from dataclasses import dataclass +from pathlib import Path + +from git_workspace.backends.models import WorkspaceBackend +from git_workspace.backends.resolver import WorkspaceBackendResolver +from git_workspace.errors import ( + GitWorkspaceError, + InvalidInputError, + WorkspacePreparationError, +) +from git_workspace.subprocesses.runner import DEFAULT_RUNNER, CommandRunner +from git_workspace.workspace.core import Workspace +from git_workspace.workspace.lock import workspace_operation_lock +from git_workspace.workspace.models import ( + ManagedWorktree, + PresenterKind, + ProviderKind, + WorkspaceLifecycleState, + WorkspaceRecord, + WorktreeRequest, +) +from git_workspace.workspace.preparer import WorkspacePreparer +from git_workspace.workspace.state import WorkspaceStateStore +from git_workspace.workspace.worktree import Worktree + +logger = logging.getLogger(__name__) + +# Lifecycle states that mean the worktree exists but its preparation never +# completed: retry preparation instead of assuming it is usable. +_UNPREPARED_STATES = frozenset( + { + WorkspaceLifecycleState.CREATED, + WorkspaceLifecycleState.PREPARING, + WorkspaceLifecycleState.PREPARATION_FAILED, + } +) + + +@dataclass(frozen=True) +class PrepareOutcome: + record: WorkspaceRecord + skipped: bool + + +@dataclass(frozen=True) +class PruneFailure: + worktree: Worktree + error: GitWorkspaceError + + +class WorkspaceService: + """ + Orchestrates the workspace lifecycle across the worktree provider, the + preparer, the presenter (when configured), and the state store. + + Every mutating operation holds a per-worktree lifecycle lock and follows + the state machine: CREATED → PREPARING → READY/PREPARATION_FAILED, with + DETACHED and TEARING_DOWN for session exit and removal. + """ + + def __init__( + self, + *, + workspace: Workspace, + backend: WorkspaceBackend, + preparer: WorkspacePreparer, + state_store: WorkspaceStateStore, + ) -> None: + self._workspace = workspace + self._backend = backend + self._preparer = preparer + self._state = state_store + + @classmethod + def create( + cls, + workspace: Workspace, + *, + backend_name: str | None = None, + provider_kind: ProviderKind | None = None, + presenter_kind: PresenterKind | None = None, + runner: CommandRunner = DEFAULT_RUNNER, + resolver: WorkspaceBackendResolver | None = None, + ) -> WorkspaceService: + backend = (resolver or WorkspaceBackendResolver(runner)).resolve( + backend_name=backend_name, + provider_kind=provider_kind, + presenter_kind=presenter_kind, + settings=workspace.manifest.workspace, + ) + return cls( + workspace=workspace, + backend=backend, + preparer=WorkspacePreparer(workspace), + state_store=WorkspaceStateStore(workspace.paths.state), + ) + + def up( + self, + branch: str | None, + *, + base_branch: str | None = None, + runtime_vars: dict[str, str] | None = None, + detached: bool = False, + effective_branch: str | None = None, + focus: bool = True, + ) -> Worktree: + """ + Ensures a prepared worktree exists for the branch and attaches to it. + + New worktrees go through the full lifecycle (create → prepare → + attach). Existing worktrees are re-prepared only when their recorded + state says preparation never completed; worktrees without state are + grandfathered as READY. + """ + runtime_vars = runtime_vars or {} + + if branch: + existing = next( + (wt for wt in Worktree.list(self._workspace) if wt.branch == branch), None + ) + else: + existing = Worktree.resolve(self._workspace, None) + + if existing is not None: + return self._up_existing( + existing, + runtime_vars=runtime_vars, + detached=detached, + effective_branch=effective_branch, + focus=focus, + ) + + assert branch is not None + return self._up_new( + branch, + base_branch=base_branch, + runtime_vars=runtime_vars, + detached=detached, + effective_branch=effective_branch, + focus=focus, + ) + + def prepare_path( + self, + worktree_path: Path, + *, + force: bool = False, + runtime_vars: dict[str, str] | None = None, + effective_branch: str | None = None, + ) -> PrepareOutcome: + """ + Prepares an existing worktree, regardless of which tool created it. + + No-ops when the worktree is already READY unless ``force`` is set. + Never creates a worktree and never touches presentation. + """ + managed = self._backend.provider.import_existing(worktree_path) + + with self._lock(managed.worktree_path): + record = self._state.load(managed.worktree_path) + if ( + record is not None + and record.lifecycle_state is WorkspaceLifecycleState.READY + and not force + ): + logger.debug("worktree %s already prepared; skipping", managed.worktree_path) + return PrepareOutcome(record=record, skipped=True) + + if record is None: + self._state.save_created(managed) + + worktree = self._worktree_for(managed) + record = self._run_prepare( + worktree, runtime_vars=runtime_vars or {}, effective_branch=effective_branch + ) + return PrepareOutcome(record=record, skipped=False) + + def down( + self, + branch: str | None, + *, + runtime_vars: dict[str, str] | None = None, + effective_branch: str | None = None, + ) -> Worktree: + """ + Runs the detach lifecycle and closes the presentation when supported. + + Never removes the worktree. + """ + worktree = Worktree.resolve(self._workspace, branch) + + with self._lock(worktree.dir): + self._preparer.detach( + worktree.dir, + worktree.branch, + runtime_vars=runtime_vars, + effective_branch=effective_branch, + ) + + record = self._state.load(worktree.dir) + managed = record.worktree if record else self._managed_for(worktree) + self._close_presentation(managed, record) + + if record is None: + self._state.save( + WorkspaceRecord( + worktree=managed, + presentation=None, + lifecycle_state=WorkspaceLifecycleState.DETACHED, + ) + ) + elif record.lifecycle_state not in _UNPREPARED_STATES: + # Preserve unfinished-preparation states so the next `up` + # still retries preparation after a detach. + self._state.set_state(worktree.dir, WorkspaceLifecycleState.DETACHED) + + return worktree + + def remove( + self, + branch: str | None, + *, + force: bool = False, + runtime_vars: dict[str, str] | None = None, + effective_branch: str | None = None, + ) -> Worktree: + """ + Removes a worktree: detach + teardown hooks, then provider removal. + + A teardown failure aborts before anything is deleted. The branch is + never deleted. + """ + worktree = Worktree.resolve(self._workspace, branch) + + with self._lock(worktree.dir): + record = self._state.load(worktree.dir) + managed = record.worktree if record else self._managed_for(worktree) + + if record is None: + self._state.save( + WorkspaceRecord( + worktree=managed, + presentation=None, + lifecycle_state=WorkspaceLifecycleState.TEARING_DOWN, + ) + ) + else: + self._state.set_state(worktree.dir, WorkspaceLifecycleState.TEARING_DOWN) + + # Close the presentation before teardown so background UI + # processes cannot hold files or services open during cleanup. + self._close_presentation(managed, record) + + self._preparer.detach( + worktree.dir, + worktree.branch, + runtime_vars=runtime_vars, + effective_branch=effective_branch, + ) + self._preparer.teardown( + worktree.dir, + worktree.branch, + runtime_vars=runtime_vars, + effective_branch=effective_branch, + ) + + self._backend.provider.remove(managed, force=force) + self._clean_intermediary_empty_paths(worktree.dir) + self._state.delete(worktree.dir) + + return worktree + + def list_worktrees(self) -> builtins.list[Worktree]: + return Worktree.list(self._workspace) + + def prune_candidates(self, *, older_than_days: int | None = None) -> builtins.list[Worktree]: + """ + Returns worktrees older than the threshold, excluding protected branches. + + The threshold falls back to the manifest's ``[prune]`` configuration. + """ + manifest = self._workspace.manifest + + threshold = older_than_days + if threshold is None: + if manifest.prune is None: + raise InvalidInputError("Must pass --older-than-days or define [prune] in manifest") + threshold = manifest.prune.older_than_days + + protected: set[str] = set(manifest.prune.exclude_branches) if manifest.prune else set() + + return [ + worktree + for worktree in Worktree.list(self._workspace) + if worktree.age_days > threshold and worktree.branch not in protected + ] + + def prune( + self, + candidates: builtins.list[Worktree], + *, + runtime_vars: dict[str, str] | None = None, + ) -> builtins.list[PruneFailure]: + """ + Removes each candidate through the full remove lifecycle. + + A failure (teardown hook, held lock) skips that worktree and pruning + continues; failures are returned for the caller to report. + """ + failures = [] + for worktree in candidates: + try: + self.remove(worktree.branch, force=True, runtime_vars=runtime_vars) + except GitWorkspaceError as e: + logger.warning("failed to prune worktree %r: %s", worktree.branch, e) + failures.append(PruneFailure(worktree=worktree, error=e)) + return failures + + def _up_new( + self, + branch: str, + *, + base_branch: str | None, + runtime_vars: dict[str, str], + detached: bool, + effective_branch: str | None, + focus: bool, + ) -> Worktree: + target = self._workspace.paths.worktree(branch) + + with self._lock(target): + managed = self._backend.provider.create( + WorktreeRequest( + repository_path=self._workspace.dir, + branch=branch, + base_branch=base_branch or self._workspace.manifest.base_branch, + target_path=target, + ) + ) + self._state.save_created(managed) + + worktree = self._worktree_for(managed, is_new=True) + self._run_prepare( + worktree, runtime_vars=runtime_vars, effective_branch=effective_branch + ) + + if not detached: + self._preparer.attach( + worktree.dir, + worktree.branch, + runtime_vars=runtime_vars, + effective_branch=effective_branch, + ) + + self._present(managed, focus=focus and not detached) + + return worktree + + def _up_existing( + self, + worktree: Worktree, + *, + runtime_vars: dict[str, str], + detached: bool, + effective_branch: str | None, + focus: bool, + ) -> Worktree: + with self._lock(worktree.dir): + record = self._state.load(worktree.dir) + managed = record.worktree if record else self._managed_for(worktree) + + if record is None: + # Legacy worktree predating the state store: assume it was + # prepared by the tool that created it. + self._state.save( + WorkspaceRecord( + worktree=managed, + presentation=None, + lifecycle_state=WorkspaceLifecycleState.READY, + ) + ) + elif record.lifecycle_state in _UNPREPARED_STATES: + self._run_prepare( + worktree, runtime_vars=runtime_vars, effective_branch=effective_branch + ) + else: + self._state.set_state(worktree.dir, WorkspaceLifecycleState.READY) + + if not detached: + self._preparer.attach( + worktree.dir, + worktree.branch, + runtime_vars=runtime_vars, + effective_branch=effective_branch, + ) + + self._present(managed, focus=focus and not detached) + + return worktree + + def _run_prepare( + self, + worktree: Worktree, + *, + runtime_vars: dict[str, str], + effective_branch: str | None, + ) -> WorkspaceRecord: + self._state.set_state(worktree.dir, WorkspaceLifecycleState.PREPARING) + try: + self._preparer.prepare( + worktree.dir, + worktree.branch, + runtime_vars=runtime_vars, + effective_branch=effective_branch, + ) + except Exception as e: + record = self._state.mark_preparation_failed(worktree.dir, error=str(e)) + raise WorkspacePreparationError(record, cause=str(e)) from e + return self._state.set_state(worktree.dir, WorkspaceLifecycleState.READY) + + def _present(self, managed: ManagedWorktree, *, focus: bool) -> None: + presenter = self._backend.presenter + if presenter is None: + return + + presentation = presenter.open(managed) + if focus: + presentation = presenter.focus(managed, presentation) + + record = self._state.load(managed.worktree_path) + if record is not None: + self._state.save( + WorkspaceRecord( + worktree=record.worktree, + presentation=presentation, + lifecycle_state=record.lifecycle_state, + preparation_error=record.preparation_error, + ) + ) + + def _close_presentation(self, managed: ManagedWorktree, record: WorkspaceRecord | None) -> None: + presenter = self._backend.presenter + if presenter is None or not presenter.capabilities.can_close: + return + presenter.close(managed, record.presentation if record else None) + + def _worktree_for(self, managed: ManagedWorktree, *, is_new: bool = False) -> Worktree: + return Worktree( + workspace=self._workspace, + dir=managed.worktree_path, + branch=managed.branch, + is_new=is_new, + ) + + def _managed_for(self, worktree: Worktree) -> ManagedWorktree: + return ManagedWorktree( + repository_path=self._workspace.dir, + worktree_path=worktree.dir, + branch=worktree.branch, + provider_kind=self._backend.provider.kind, + ) + + def _lock(self, worktree_path: Path): + return workspace_operation_lock(self._state.locks_dir, worktree_path) + + def _clean_intermediary_empty_paths(self, worktree_dir: Path) -> None: + parent = worktree_dir.parent + while parent != self._workspace.dir: + try: + parent.rmdir() + logger.debug("removed empty intermediary directory: %s", parent) + except OSError: + break + parent = parent.parent diff --git a/src/git_workspace/workspace/state.py b/src/git_workspace/workspace/state.py new file mode 100644 index 0000000..aa9feac --- /dev/null +++ b/src/git_workspace/workspace/state.py @@ -0,0 +1,214 @@ +import builtins +import hashlib +import json +import logging +import os +import re +from pathlib import Path + +from git_workspace.errors import WorkspaceStateError +from git_workspace.workspace.models import ( + ManagedWorktree, + Presentation, + PresenterKind, + ProviderKind, + WorkspaceLifecycleState, + WorkspaceRecord, +) + +logger = logging.getLogger(__name__) + +SCHEMA_VERSION = 1 + + +def state_file_stem(worktree_path: Path) -> str: + """ + Deterministic per-worktree file stem: a readable slug plus a canonical-path + hash for identity. + """ + canonical = worktree_path.expanduser().resolve() + slug = re.sub(r"[^A-Za-z0-9._-]+", "-", canonical.name)[:40] or "worktree" + digest = hashlib.sha256(str(canonical).encode()).hexdigest()[:16] + return f"{slug}-{digest}" + + +class WorkspaceStateStore: + """ + Persists per-worktree lifecycle state as JSON files under + ``/.workspace/.state/``. + + Persisted state is bookkeeping, not the source of truth for git existence: + a missing state file is always valid (legacy worktree) and a corrupt one is + treated as missing rather than blocking operations. Only a newer schema + version is a hard error, since guessing across schemas before destructive + operations is unsafe. + """ + + GITIGNORE_CONTENT = "*\n!.gitignore\n" + + def __init__(self, state_dir: Path) -> None: + self._state_dir = state_dir + + @property + def state_dir(self) -> Path: + return self._state_dir + + @property + def locks_dir(self) -> Path: + return self._state_dir / "locks" + + def load(self, worktree_path: Path) -> WorkspaceRecord | None: + path = self._path_for(worktree_path) + try: + raw = json.loads(path.read_text()) + except FileNotFoundError: + return None + except (OSError, json.JSONDecodeError) as e: + logger.warning("unreadable state file %s (%s); treating as missing", path, e) + return None + + schema_version = raw.get("schema_version") + if not isinstance(schema_version, int) or schema_version > SCHEMA_VERSION: + raise WorkspaceStateError( + f"State file {path} uses schema version {schema_version!r}, but this version of " + f"git-workspace supports up to {SCHEMA_VERSION}. Upgrade git-workspace to proceed." + ) + + try: + return self._deserialize(raw) + except (KeyError, TypeError, ValueError) as e: + logger.warning("malformed state file %s (%s); treating as missing", path, e) + return None + + def save(self, record: WorkspaceRecord) -> WorkspaceRecord: + self._write(self._path_for(record.worktree.worktree_path), self._serialize(record)) + return record + + def save_created(self, worktree: ManagedWorktree) -> WorkspaceRecord: + return self.save( + WorkspaceRecord( + worktree=worktree, + presentation=None, + lifecycle_state=WorkspaceLifecycleState.CREATED, + ) + ) + + def set_state(self, worktree_path: Path, state: WorkspaceLifecycleState) -> WorkspaceRecord: + record = self._require(worktree_path) + return self.save( + WorkspaceRecord( + worktree=record.worktree, + presentation=record.presentation, + lifecycle_state=state, + preparation_error=None, + ) + ) + + def mark_preparation_failed(self, worktree_path: Path, *, error: str) -> WorkspaceRecord: + record = self._require(worktree_path) + return self.save( + WorkspaceRecord( + worktree=record.worktree, + presentation=record.presentation, + lifecycle_state=WorkspaceLifecycleState.PREPARATION_FAILED, + preparation_error=error, + ) + ) + + def delete(self, worktree_path: Path) -> None: + self._path_for(worktree_path).unlink(missing_ok=True) + + def list(self) -> builtins.list[WorkspaceRecord]: + if not self._state_dir.is_dir(): + return [] + + records = [] + for path in sorted(self._state_dir.glob("*.json")): + record = self.load_file(path) + if record is not None: + records.append(record) + return records + + def load_file(self, path: Path) -> WorkspaceRecord | None: + try: + raw = json.loads(path.read_text()) + return self._deserialize(raw) + except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as e: + logger.warning("skipping unreadable state file %s (%s)", path, e) + return None + + def _require(self, worktree_path: Path) -> WorkspaceRecord: + record = self.load(worktree_path) + if record is None: + raise WorkspaceStateError( + f"No workspace state recorded for {worktree_path.expanduser().resolve()}" + ) + return record + + def _path_for(self, worktree_path: Path) -> Path: + return self._state_dir / f"{state_file_stem(worktree_path)}.json" + + def _write(self, path: Path, payload: dict) -> None: + self._ensure_state_dir() + tmp_path = path.with_name(f"{path.name}.tmp.{os.getpid()}") + tmp_path.write_text(json.dumps(payload, indent=2) + "\n") + os.replace(tmp_path, path) + + def _ensure_state_dir(self) -> None: + # .workspace is itself a git repo, so keep state out of its index. + self._state_dir.mkdir(parents=True, exist_ok=True) + gitignore = self._state_dir / ".gitignore" + if not gitignore.exists(): + gitignore.write_text(self.GITIGNORE_CONTENT) + + @staticmethod + def _serialize(record: WorkspaceRecord) -> dict: + presentation = record.presentation + return { + "schema_version": SCHEMA_VERSION, + "repository_path": str(record.worktree.repository_path), + "worktree_path": str(record.worktree.worktree_path), + "branch": record.worktree.branch, + "provider": { + "kind": record.worktree.provider_kind.value, + "provider_id": record.worktree.provider_id, + "metadata": dict(record.worktree.metadata), + }, + "presenter": ( + None + if presentation is None + else { + "kind": presentation.presenter_kind.value, + "presentation_id": presentation.presentation_id, + "metadata": dict(presentation.metadata), + } + ), + "lifecycle_state": record.lifecycle_state.value, + "preparation_error": record.preparation_error, + } + + @staticmethod + def _deserialize(raw: dict) -> WorkspaceRecord: + provider = raw.get("provider") or {} + presenter = raw.get("presenter") + return WorkspaceRecord( + worktree=ManagedWorktree( + repository_path=Path(raw["repository_path"]), + worktree_path=Path(raw["worktree_path"]), + branch=raw["branch"], + provider_kind=ProviderKind(provider.get("kind", ProviderKind.NATIVE_GIT.value)), + provider_id=provider.get("provider_id"), + metadata=provider.get("metadata") or {}, + ), + presentation=( + None + if presenter is None + else Presentation( + presenter_kind=PresenterKind(presenter["kind"]), + presentation_id=presenter.get("presentation_id"), + metadata=presenter.get("metadata") or {}, + ) + ), + lifecycle_state=WorkspaceLifecycleState(raw["lifecycle_state"]), + preparation_error=raw.get("preparation_error"), + ) diff --git a/src/git_workspace/workspace/worktree.py b/src/git_workspace/workspace/worktree.py new file mode 100644 index 0000000..508a240 --- /dev/null +++ b/src/git_workspace/workspace/worktree.py @@ -0,0 +1,117 @@ +import builtins +import logging +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING + +from git_workspace.errors import WorktreeResolutionError +from git_workspace.subprocesses import git +from git_workspace.utils import directory_birthtime + +if TYPE_CHECKING: + from git_workspace.workspace.core import Workspace + +logger = logging.getLogger(__name__) + + +@dataclass +class Worktree: + """ + Represents a git worktree within a workspace. + + Each worktree corresponds to a single branch checked out under the workspace + root directory. The ``is_new`` flag indicates that the worktree was just + created in the current operation rather than resolved from an existing one, + which triggers setup hooks and asset linking. + + ``timestamp`` records when the worktree directory was created, used to + compute the worktree's age in days. + """ + + workspace: Workspace + dir: Path + branch: str + is_new: bool = False + timestamp: datetime = field(default_factory=datetime.now) + + @property + def age_days(self) -> int: + """Returns the number of full days since the worktree directory was created.""" + return (datetime.now() - self.timestamp).days + + @classmethod + def list(cls, workspace: Workspace) -> builtins.list[Worktree]: + """ + Returns all worktrees currently registered in the workspace. + + :param workspace: The workspace whose worktrees should be listed. + :returns: List of ``Worktree`` instances, one per registered git worktree. + :raises WorktreeListingError: If ``git worktree list`` fails. + """ + return [ + Worktree( + workspace=workspace, + dir=managed.worktree_path, + branch=managed.branch, + is_new=False, + timestamp=directory_birthtime(managed.worktree_path), + ) + for managed in workspace.provider.list(workspace.dir) + ] + + @classmethod + def _try_resolve_existing(cls, workspace: Workspace, branch: str) -> Worktree | None: + existing_worktrees = cls.list(workspace) + worktree = next((wt for wt in existing_worktrees if wt.branch == branch), None) + if worktree: + logger.debug("found existing worktree for branch %r at %s", branch, worktree.dir) + else: + logger.debug("no existing worktree for branch %r", branch) + return worktree + + @classmethod + def _resolve_from_cwd( + cls, + workspace: Workspace, + ) -> Worktree: + logger.debug("resolving worktree from cwd") + worktree_dir = git.try_get_worktree_dir() + if worktree_dir is None: + cwd = Path.cwd() + logger.warning("cwd is not inside a git worktree") + raise WorktreeResolutionError( + f"Cannot resolve worktree from cwd: {cwd!r} is not inside a git worktree" + ) + branch = git.get_worktree_branch(cwd=worktree_dir) + logger.debug("resolved worktree from cwd: branch=%r dir=%s", branch, worktree_dir) + + return Worktree( + workspace=workspace, + dir=Path(worktree_dir).resolve(), + branch=branch, + is_new=False, + ) + + @classmethod + def resolve(cls, workspace: Workspace, branch: str | None) -> Worktree: + """ + Resolves an existing worktree by branch name or from the current working directory. + + If ``branch`` is provided, searches the registered worktrees for an exact + match and raises if none is found. If ``branch`` is ``None``, the worktree + is inferred from the current working directory. + + :param workspace: The workspace to search within. + :param branch: The branch name to look up, or ``None`` to resolve from cwd. + :returns: The matching ``Worktree`` instance. + :raises WorktreeResolutionError: If no worktree can be resolved. + """ + if branch: + worktree = cls._try_resolve_existing(workspace, branch) + if not worktree: + logger.warning("no worktree found for branch %r", branch) + raise WorktreeResolutionError(f"No worktree found for branch {branch!r}") + return worktree + else: + return cls._resolve_from_cwd(workspace) diff --git a/src/git_workspace/worktree.py b/src/git_workspace/worktree.py deleted file mode 100644 index 93f4673..0000000 --- a/src/git_workspace/worktree.py +++ /dev/null @@ -1,274 +0,0 @@ -import builtins -import logging -from dataclasses import dataclass, field -from datetime import datetime -from pathlib import Path -from typing import TYPE_CHECKING - -from git_workspace import git -from git_workspace.errors import GitFetchError, WorktreeResolutionError -from git_workspace.utils import directory_birthtime - -if TYPE_CHECKING: - from git_workspace.workspace import Workspace - -logger = logging.getLogger(__name__) - - -@dataclass -class Worktree: - """ - Represents a git worktree within a workspace. - - Each worktree corresponds to a single branch checked out under the workspace - root directory. The ``is_new`` flag indicates that the worktree was just - created in the current operation rather than resolved from an existing one, - which triggers setup hooks and asset linking. - - ``timestamp`` records when the worktree directory was created, used to - compute the worktree's age in days. - """ - - workspace: Workspace - dir: Path - branch: str - is_new: bool = False - timestamp: datetime = field(default_factory=datetime.now) - - @property - def age_days(self) -> int: - """Returns the number of full days since the worktree directory was created.""" - return (datetime.now() - self.timestamp).days - - @classmethod - def list(cls, workspace: Workspace) -> builtins.list[Worktree]: - """ - Returns all worktrees currently registered in the workspace. - - :param workspace: The workspace whose worktrees should be listed. - :returns: List of ``Worktree`` instances, one per registered git worktree. - :raises WorktreeListingError: If ``git worktree list`` fails. - """ - raw_worktrees = git.list_worktrees(cwd=workspace.dir) - return [ - Worktree( - workspace=workspace, - dir=(d := Path(raw_worktree["directory"]).resolve()), - branch=raw_worktree["branch"], - is_new=False, - timestamp=directory_birthtime(d), - ) - for raw_worktree in raw_worktrees - ] - - @classmethod - def _try_resolve_existing(cls, workspace: Workspace, branch: str) -> Worktree | None: - existing_worktrees = cls.list(workspace) - worktree = next((wt for wt in existing_worktrees if wt.branch == branch), None) - if worktree: - logger.debug("found existing worktree for branch %r at %s", branch, worktree.dir) - else: - logger.debug("no existing worktree for branch %r", branch) - return worktree - - @classmethod - def _try_create_from_local_branch( - cls, - workspace: Workspace, - branch: str, - ) -> Worktree | None: - if not git.local_branch_exists(branch, cwd=workspace.dir): - logger.debug("local branch %r not found, skipping", branch) - return None - - logger.info("creating worktree from local branch %r", branch) - dir = workspace.paths.worktree(branch) - git.create_worktree_from_local_branch(dir, branch, cwd=workspace.dir) - - return Worktree( - workspace=workspace, - dir=dir, - branch=branch, - is_new=True, - ) - - @classmethod - def _try_create_from_remote_branch( - cls, - workspace: Workspace, - branch: str, - ) -> Worktree | None: - try: - git.fetch_origin(cwd=workspace.dir) - except GitFetchError: - logger.debug("fetch failed, skipping remote branch lookup for %r", branch) - return None - - if not git.remote_branch_exists(branch, cwd=workspace.dir): - logger.debug("remote branch %r not found, skipping", branch) - return None - - logger.info("creating worktree from remote branch %r", branch) - dir = workspace.paths.worktree(branch) - git.create_worktree_from_remote_branch( - dir, - branch, - cwd=workspace.dir, - ) - - return Worktree( - workspace=workspace, - dir=dir, - branch=branch, - is_new=True, - ) - - @classmethod - def _create_new( - cls, - workspace: Workspace, - branch: str, - base_branch: str | None, - ) -> Worktree: - resolved_base_branch = base_branch or workspace.manifest.base_branch - - logger.info( - "creating new worktree for branch %r from base %r", - branch, - resolved_base_branch, - ) - - try: - git.fetch_origin(cwd=workspace.dir) - except GitFetchError: - pass # offline – proceed with whatever local refs exist - - # Prefer origin/ so we always fork from the latest remote commit, - # not a local ref that may be stale or locked by an active worktree. - base_ref = ( - f"origin/{resolved_base_branch}" - if git.remote_branch_exists(resolved_base_branch, cwd=workspace.dir) - else resolved_base_branch - ) - - dir = workspace.paths.worktree(branch) - git.create_worktree_new( - dir, - branch, - base_ref, - cwd=workspace.dir, - ) - - return Worktree( - workspace=workspace, - dir=dir, - branch=branch, - is_new=True, - ) - - @classmethod - def _resolve_from_cwd( - cls, - workspace: Workspace, - ) -> Worktree: - logger.debug("resolving worktree from cwd") - worktree_dir = git.try_get_worktree_dir() - if worktree_dir is None: - cwd = Path.cwd() - logger.warning("cwd is not inside a git worktree") - raise WorktreeResolutionError( - f"Cannot resolve worktree from cwd: {cwd!r} is not inside a git worktree" - ) - branch = git.get_worktree_branch(cwd=worktree_dir) - logger.debug("resolved worktree from cwd: branch=%r dir=%s", branch, worktree_dir) - - return Worktree( - workspace=workspace, - dir=Path(worktree_dir).resolve(), - branch=branch, - is_new=False, - ) - - @classmethod - def resolve(cls, workspace: Workspace, branch: str | None) -> Worktree: - """ - Resolves an existing worktree by branch name or from the current working directory. - - If ``branch`` is provided, searches the registered worktrees for an exact - match and raises if none is found. If ``branch`` is ``None``, the worktree - is inferred from the current working directory. - - :param workspace: The workspace to search within. - :param branch: The branch name to look up, or ``None`` to resolve from cwd. - :returns: The matching ``Worktree`` instance. - :raises WorktreeResolutionError: If no worktree can be resolved. - """ - if branch: - worktree = cls._try_resolve_existing(workspace, branch) - if not worktree: - logger.warning("no worktree found for branch %r", branch) - raise WorktreeResolutionError(f"No worktree found for branch {branch!r}") - return worktree - else: - return cls._resolve_from_cwd(workspace) - - @classmethod - def resolve_or_create( - cls, - workspace: Workspace, - branch: str | None, - base_branch: str | None, - ) -> Worktree: - """ - Resolves an existing worktree or creates a new one for the given branch. - - Resolution is attempted in order: existing worktree → local branch → - remote branch → brand new branch from ``base_branch``. If ``branch`` is - ``None``, the worktree is resolved from the current working directory - without creation. - - :param workspace: The workspace to operate on. - :param branch: The branch name to resolve or create, or ``None`` to - resolve from cwd. - :param base_branch: The branch to base a new branch on. Falls back to - the manifest's ``base_branch`` if ``None``. - :returns: The resolved or newly created ``Worktree`` instance. - :raises WorktreeResolutionError: If ``branch`` is ``None`` and the cwd - is not inside a known worktree. - :raises WorktreeCreationError: If worktree creation fails at the git level. - """ - if branch: - logger.debug("resolving or creating worktree for branch %r", branch) - return ( - cls._try_resolve_existing(workspace, branch) - or cls._try_create_from_local_branch(workspace, branch) - or cls._try_create_from_remote_branch(workspace, branch) - or cls._create_new(workspace, branch, base_branch) - ) - else: - return cls._resolve_from_cwd(workspace) - - def _clean_intermediary_empty_paths(self) -> None: - parent = self.dir.parent - while parent != self.workspace.dir: - try: - parent.rmdir() - logger.debug("removed empty intermediary directory: %s", parent) - except OSError: - break - parent = parent.parent - - def delete(self, force: bool) -> None: - """ - Removes this worktree and cleans up any empty intermediary directories. - - Delegates to ``git worktree remove`` and then walks up the directory - tree removing empty parents until the workspace root is reached. - - :param force: If ``True``, removes the worktree even if it has - uncommitted changes. - :raises WorktreeRemovalError: If ``git worktree remove`` fails. - """ - logger.info("deleting worktree for branch %r at %s", self.branch, self.dir) - git.remove_worktree(self.dir, force, cwd=self.workspace.dir) - self._clean_intermediary_empty_paths() diff --git a/tests/conftest.py b/tests/conftest.py index 2a45696..065402a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,3 +7,21 @@ def quiet_console(monkeypatch): monkeypatch.setattr("git_workspace.ui._console.print", lambda *a, **kw: None) monkeypatch.setattr(console, "_impl", PlainUI()) + + +@pytest.fixture(autouse=True) +def no_herdr_context(monkeypatch): + """ + Scrubs herdr's environment markers so backend auto-detection always + resolves native in tests — even when the suite itself runs inside a herdr + session. Tests exercising the herdr backend set their own markers. + """ + for var in ( + "HERDR_ENV", + "HERDR_SOCKET_PATH", + "HERDR_BIN_PATH", + "HERDR_WORKSPACE_ID", + "HERDR_TAB_ID", + "HERDR_PANE_ID", + ): + monkeypatch.delenv(var, raising=False) diff --git a/tests/contracts/__init__.py b/tests/contracts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/contracts/presenter.py b/tests/contracts/presenter.py new file mode 100644 index 0000000..ae5c242 --- /dev/null +++ b/tests/contracts/presenter.py @@ -0,0 +1,85 @@ +""" +Reusable contract tests every WorkspacePresenter implementation must pass. + +Subclasses provide the `presenter` fixture and a `worktree` fixture pointing +at a real, registered-but-not-presented worktree. Operations an +implementation does not support (see `capabilities`) are skipped here — the +implementation's own tests must assert they raise PresenterCapabilityError. +""" + +from pathlib import Path + +import pytest + +from git_workspace.presenters.base import WorkspacePresenter +from git_workspace.workspace.models import ManagedWorktree + + +class WorkspacePresenterContract: + @pytest.fixture + def presenter(self) -> WorkspacePresenter: + raise NotImplementedError("subclasses must provide a presenter fixture") + + @pytest.fixture + def worktree(self, tmp_path: Path) -> ManagedWorktree: + raise NotImplementedError("subclasses must provide a worktree fixture") + + def test_kind_is_stable(self, presenter: WorkspacePresenter) -> None: + assert presenter.kind == presenter.kind + assert isinstance(presenter.kind.value, str) + + def test_open_is_idempotent_when_supported( + self, presenter: WorkspacePresenter, worktree: ManagedWorktree + ) -> None: + first = presenter.open(worktree) + second = presenter.open(worktree) + + assert first.presentation_id == second.presentation_id + + def test_find_uses_canonical_path( + self, presenter: WorkspacePresenter, worktree: ManagedWorktree, tmp_path: Path + ) -> None: + if not presenter.capabilities.can_find_existing: + pytest.skip("presenter cannot find existing presentations") + + opened = presenter.open(worktree) + alias = tmp_path / "presenter-alias" + alias.symlink_to(worktree.worktree_path) + + found = presenter.find(alias) + + assert found is not None + assert found.presentation_id == opened.presentation_id + + def test_find_returns_none_when_not_presented( + self, presenter: WorkspacePresenter, worktree: ManagedWorktree + ) -> None: + if not presenter.capabilities.can_find_existing: + pytest.skip("presenter cannot find existing presentations") + + assert presenter.find(worktree.worktree_path) is None + + def test_close_preserves_worktree( + self, presenter: WorkspacePresenter, worktree: ManagedWorktree + ) -> None: + if not presenter.capabilities.can_close: + pytest.skip("presenter cannot close presentations") + + presentation = presenter.open(worktree) + + presenter.close(worktree, presentation) + + assert worktree.worktree_path.is_dir() + if presenter.capabilities.can_find_existing: + assert presenter.find(worktree.worktree_path) is None + + def test_close_is_idempotent( + self, presenter: WorkspacePresenter, worktree: ManagedWorktree + ) -> None: + if not presenter.capabilities.can_close: + pytest.skip("presenter cannot close presentations") + + presentation = presenter.open(worktree) + + presenter.close(worktree, presentation) + presenter.close(worktree, presentation) diff --git a/tests/contracts/provider.py b/tests/contracts/provider.py new file mode 100644 index 0000000..89cc846 --- /dev/null +++ b/tests/contracts/provider.py @@ -0,0 +1,156 @@ +""" +Reusable contract tests every WorktreeProvider implementation must pass. + +Subclasses provide the `provider` fixture and a `repo` fixture pointing at a +real repository the provider can operate on. Command-construction specifics +belong in per-provider unit tests; this contract asserts observable behavior +only, so future providers can subclass it unchanged. + +A matching WorkspacePresenterContract will be added once the first presenter +implementation exists. +""" + +import subprocess +from pathlib import Path + +import pytest + +from git_workspace.errors import ( + ProviderError, + WorktreeAlreadyExistsError, + WorktreeNotFoundError, +) +from git_workspace.providers.base import WorktreeProvider +from git_workspace.workspace.models import ManagedWorktree, WorktreeRequest + + +class WorktreeProviderContract: + @pytest.fixture + def provider(self) -> WorktreeProvider: + raise NotImplementedError("subclasses must provide a provider fixture") + + @pytest.fixture + def repo(self, tmp_path: Path) -> Path: + raise NotImplementedError("subclasses must provide a repository fixture") + + @pytest.fixture + def registered_worktree(self, provider: WorktreeProvider, repo: Path) -> ManagedWorktree: + return provider.create( + WorktreeRequest( + repository_path=repo, + branch="contract/existing", + base_branch="main", + target_path=repo / "worktrees" / "existing", + ) + ) + + def _branch_exists(self, repo: Path, branch: str) -> bool: + result = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], + cwd=repo, + capture_output=True, + ) + return result.returncode == 0 + + def test_kind_is_stable(self, provider: WorktreeProvider) -> None: + assert provider.kind == provider.kind + assert isinstance(provider.kind.value, str) + + def test_is_available_does_not_mutate(self, provider: WorktreeProvider, repo: Path) -> None: + before = provider.list(repo) + + provider.is_available() + + assert provider.list(repo) == before + + def test_create_registers_worktree( + self, provider: WorktreeProvider, registered_worktree: ManagedWorktree, repo: Path + ) -> None: + assert registered_worktree.worktree_path.is_dir() + assert registered_worktree in provider.list(repo) + + def test_create_conflicting_target_raises( + self, provider: WorktreeProvider, registered_worktree: ManagedWorktree, repo: Path + ) -> None: + with pytest.raises(WorktreeAlreadyExistsError): + provider.create( + WorktreeRequest( + repository_path=repo, + branch="contract/other", + base_branch="main", + target_path=registered_worktree.worktree_path, + ) + ) + + def test_import_existing_is_idempotent( + self, provider: WorktreeProvider, registered_worktree: ManagedWorktree + ) -> None: + first = provider.import_existing(registered_worktree.worktree_path) + second = provider.import_existing(registered_worktree.worktree_path) + + assert first == second + assert first.worktree_path == registered_worktree.worktree_path + + def test_import_existing_rejects_unregistered_path( + self, provider: WorktreeProvider, tmp_path: Path + ) -> None: + unregistered = tmp_path / "not-a-worktree" + unregistered.mkdir() + + with pytest.raises(WorktreeNotFoundError): + provider.import_existing(unregistered) + + def test_find_uses_canonical_path( + self, provider: WorktreeProvider, registered_worktree: ManagedWorktree, tmp_path: Path + ) -> None: + alias = tmp_path / "alias" + alias.symlink_to(registered_worktree.worktree_path) + + found = provider.find(alias) + + assert found is not None + assert found.worktree_path == registered_worktree.worktree_path + + def test_find_returns_none_for_unknown_path( + self, provider: WorktreeProvider, tmp_path: Path + ) -> None: + unknown = tmp_path / "unknown" + unknown.mkdir() + + assert provider.find(unknown) is None + + def test_list_filters_by_repository( + self, provider: WorktreeProvider, registered_worktree: ManagedWorktree, repo: Path + ) -> None: + worktrees = provider.list(repo) + + assert all(wt.repository_path == repo.expanduser().resolve() for wt in worktrees) + assert registered_worktree in worktrees + + def test_remove_preserves_branch( + self, provider: WorktreeProvider, registered_worktree: ManagedWorktree, repo: Path + ) -> None: + provider.remove(registered_worktree) + + assert not registered_worktree.worktree_path.exists() + assert self._branch_exists(repo, registered_worktree.branch) + + def test_remove_rejects_dirty_worktree( + self, provider: WorktreeProvider, registered_worktree: ManagedWorktree + ) -> None: + (registered_worktree.worktree_path / "untracked.txt").write_text("dirty") + + with pytest.raises(ProviderError): + provider.remove(registered_worktree) + + assert registered_worktree.worktree_path.is_dir() + + def test_remove_force_removes_dirty_worktree( + self, provider: WorktreeProvider, registered_worktree: ManagedWorktree, repo: Path + ) -> None: + (registered_worktree.worktree_path / "untracked.txt").write_text("dirty") + + provider.remove(registered_worktree, force=True) + + assert not registered_worktree.worktree_path.exists() + assert self._branch_exists(repo, registered_worktree.branch) diff --git a/tests/helpers.py b/tests/helpers.py index 28679e6..ac59dce 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,6 +1,11 @@ +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path from unittest.mock import MagicMock from git_workspace.cli.callbacks import Context +from git_workspace.errors import ExternalCommandError +from git_workspace.subprocesses.runner import CommandResult def make_context(workspace_dir: str | None = None) -> MagicMock: @@ -8,3 +13,72 @@ def make_context(workspace_dir: str | None = None) -> MagicMock: ctx.obj = Context(workspace_dir) ctx.args = [] return ctx + + +@dataclass(frozen=True) +class FakeCall: + args: tuple[str, ...] + cwd: Path | None + env: Mapping[str, str] | None + check: bool + + +@dataclass +class FakeCommandRunner: + """ + CommandRunner test double: records every call and replays queued results. + + Queued results are consumed in FIFO order; once the queue is empty, every + call returns ``default_exit_code``/``default_stdout``/``default_stderr``. + """ + + default_exit_code: int = 0 + default_stdout: str = "" + default_stderr: str = "" + calls: list[FakeCall] = field(default_factory=list) + _queue: list[CommandResult] = field(default_factory=list) + + def queue(self, *, exit_code: int = 0, stdout: str = "", stderr: str = "") -> None: + self._queue.append( + CommandResult(args=(), exit_code=exit_code, stdout=stdout, stderr=stderr) + ) + + @property + def last_call(self) -> FakeCall: + return self.calls[-1] + + def run( + self, + args: Sequence[str | Path], + *, + cwd: Path | None = None, + env: Mapping[str, str] | None = None, + check: bool = True, + ) -> CommandResult: + str_args = tuple(str(arg) for arg in args) + self.calls.append(FakeCall(args=str_args, cwd=cwd, env=env, check=check)) + + if self._queue: + queued = self._queue.pop(0) + result = CommandResult( + args=str_args, + exit_code=queued.exit_code, + stdout=queued.stdout, + stderr=queued.stderr, + ) + else: + result = CommandResult( + args=str_args, + exit_code=self.default_exit_code, + stdout=self.default_stdout, + stderr=self.default_stderr, + ) + + if check and not result.ok: + raise ExternalCommandError( + command=list(str_args), + exit_code=result.exit_code, + stdout=result.stdout, + stderr=result.stderr, + ) + return result diff --git a/tests/integration/fixtures/fake_herdr.py b/tests/integration/fixtures/fake_herdr.py new file mode 100755 index 0000000..76c34cb --- /dev/null +++ b/tests/integration/fixtures/fake_herdr.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Fake herdr executable for integration tests. + +Emulates the herdr CLI surface git-workspace consumes — worktree +list/create/open/remove and workspace focus/close — with real git commands +underneath and a JSON state file (env FAKE_HERDR_STATE) standing in for the +herdr server's workspace registry. Output shapes mirror herdr 0.7.x. +""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +PORCELAIN_RE = re.compile( + r"worktree (?P.+)\n" + r"HEAD (?P[a-f0-9]{40})\n" + r"(?Pbranch refs/heads/(?P.+)|detached|bare)?" +) + + +def state_path() -> Path: + raw = os.environ.get("FAKE_HERDR_STATE") + if not raw: + fail("no_state", "FAKE_HERDR_STATE is not set") + raise AssertionError("unreachable") + return Path(raw) + + +def load_state() -> dict: + # This script runs under the system python3 (shebang), which may predate + # the project's required Python — keep the syntax conservative. + try: + return json.loads(state_path().read_text()) + except Exception: + return {"next": 1, "workspaces": {}, "focus_log": []} + + +def save_state(state: dict) -> None: + state_path().write_text(json.dumps(state, indent=2)) + + +def ok(command: str, result: dict) -> None: + print(json.dumps({"id": f"cli:{command}", "result": result})) + sys.exit(0) + + +def fail(code: str, message: str, *, exit_code: int = 1) -> None: + print(json.dumps({"error": {"code": code, "message": message}, "id": "cli:error"})) + sys.exit(exit_code) + + +def git(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess: + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True) + + +def repo_root(path: Path) -> Path: + result = git(["rev-parse", "--git-common-dir"], cwd=path) + if result.returncode != 0: + fail("not_git_worktree", "Herdr worktree actions require a path inside a Git work tree") + return (path / result.stdout.strip()).resolve().parent + + +def parse_flags(argv: list[str]) -> tuple[dict, list[str]]: + flags: dict[str, str | bool] = {} + positional = [] + i = 0 + while i < len(argv): + arg = argv[i] + if arg.startswith("--"): + name = arg[2:] + if name in {"json", "force", "focus", "no-focus"}: + flags[name] = True + i += 1 + else: + flags[name] = argv[i + 1] + i += 2 + else: + positional.append(arg) + i += 1 + return flags, positional + + +def list_entries(root: Path, state: dict) -> list[dict]: + result = git(["worktree", "list", "--porcelain"], cwd=root) + if result.returncode != 0: + fail("not_git_worktree", result.stderr.strip()) + + open_by_path = { + info["path"]: workspace_id for workspace_id, info in state["workspaces"].items() + } + + entries = [] + for block in result.stdout.split("\n\n"): + match = PORCELAIN_RE.search(block) + if not match: + continue + directory = str(Path(match.group("directory")).resolve()) + entry = { + "is_bare": "bare" in block.splitlines(), + "is_detached": match.group("rest") == "detached", + "is_linked_worktree": match.group("branch") is not None, + "is_prunable": False, + "label": root.name, + "path": directory, + } + if match.group("branch"): + entry["branch"] = match.group("branch") + if directory in open_by_path: + entry["open_workspace_id"] = open_by_path[directory] + entries.append(entry) + return entries + + +def source_block(root: Path) -> dict: + return { + "repo_key": str(root / ".git"), + "repo_name": root.name, + "repo_root": str(root), + "source_checkout_path": str(root), + } + + +def workspace_block(workspace_id: str, path: Path, root: Path) -> dict: + return { + "active_tab_id": f"{workspace_id}:t1", + "agent_status": "unknown", + "focused": False, + "label": path.name, + "number": 1, + "pane_count": 1, + "tab_count": 1, + "workspace_id": workspace_id, + "worktree": { + "checkout_path": str(path), + "is_linked_worktree": True, + "repo_key": str(root / ".git"), + "repo_name": root.name, + "repo_root": str(root), + }, + } + + +def register_workspace(state: dict, path: Path) -> str: + workspace_id = f"w{state['next']}" + state["next"] += 1 + state["workspaces"][workspace_id] = {"path": str(path)} + save_state(state) + return workspace_id + + +def cmd_worktree_list(flags: dict) -> None: + root = repo_root(Path(flags["cwd"])) + state = load_state() + ok( + "worktree:list", + { + "source": source_block(root), + "type": "worktree_list", + "worktrees": list_entries(root, state), + }, + ) + + +def cmd_worktree_create(flags: dict) -> None: + root = repo_root(Path(flags["cwd"])) + branch = flags["branch"] + target = Path(flags["path"]).resolve() + + cmd = ["worktree", "add", "-b", branch, str(target)] + if "base" in flags: + cmd.append(flags["base"]) + result = git(cmd, cwd=root) + if result.returncode != 0: + fail("worktree_create_failed", result.stderr.strip()) + + state = load_state() + workspace_id = register_workspace(state, target) + entry = { + "branch": branch, + "is_bare": False, + "is_detached": False, + "is_linked_worktree": True, + "is_prunable": False, + "label": root.name, + "open_workspace_id": workspace_id, + "path": str(target), + } + ok( + "worktree:create", + { + "type": "worktree_created", + "workspace": workspace_block(workspace_id, target, root), + "worktree": entry, + }, + ) + + +def cmd_worktree_open(flags: dict) -> None: + root = repo_root(Path(flags["cwd"])) + target = Path(flags["path"]).resolve() + state = load_state() + + existing = next( + (wid for wid, info in state["workspaces"].items() if info["path"] == str(target)), + None, + ) + already_open = existing is not None + workspace_id = existing or register_workspace(state, target) + + ok( + "worktree:open", + { + "already_open": already_open, + "type": "worktree_opened", + "workspace": workspace_block(workspace_id, target, root), + }, + ) + + +def cmd_worktree_remove(flags: dict) -> None: + state = load_state() + workspace_id = flags["workspace"] + info = state["workspaces"].get(workspace_id) + if info is None: + fail("workspace_not_found", f"workspace {workspace_id} not found") + + path = Path(info["path"]) + root = repo_root(path if path.exists() else path.parent) + cmd = ["worktree", "remove"] + if flags.get("force"): + cmd.append("--force") + cmd.append(str(path)) + result = git(cmd, cwd=root) + if result.returncode != 0: + fail("worktree_remove_failed", result.stderr.strip()) + + del state["workspaces"][workspace_id] + save_state(state) + ok( + "worktree:remove", + { + "forced": bool(flags.get("force")), + "path": str(path), + "type": "worktree_removed", + "workspace_id": workspace_id, + }, + ) + + +def cmd_workspace_focus(workspace_id: str) -> None: + state = load_state() + if workspace_id not in state["workspaces"]: + fail("workspace_not_found", f"workspace {workspace_id} not found") + state["focus_log"].append(workspace_id) + save_state(state) + ok("workspace:focus", {"type": "ok"}) + + +def cmd_workspace_close(workspace_id: str) -> None: + state = load_state() + if workspace_id not in state["workspaces"]: + fail("workspace_not_found", f"workspace {workspace_id} not found") + del state["workspaces"][workspace_id] + save_state(state) + ok("workspace:close", {"type": "ok"}) + + +def cmd_notification_show(flags: dict, positional: list) -> None: + state = load_state() + state.setdefault("notifications", []).append( + {"title": positional[0], "body": flags.get("body")} + ) + save_state(state) + ok("notification:show", {"type": "ok"}) + + +def main() -> None: + argv = sys.argv[1:] + if len(argv) < 2: + fail("usage", "fake herdr requires a group and a subcommand", exit_code=2) + + group, sub = argv[0], argv[1] + flags, positional = parse_flags(argv[2:]) + + if group == "worktree" and sub == "list": + cmd_worktree_list(flags) + elif group == "worktree" and sub == "create": + cmd_worktree_create(flags) + elif group == "worktree" and sub == "open": + cmd_worktree_open(flags) + elif group == "worktree" and sub == "remove": + cmd_worktree_remove(flags) + elif group == "workspace" and sub == "focus": + cmd_workspace_focus(positional[0]) + elif group == "workspace" and sub == "close": + cmd_workspace_close(positional[0]) + elif group == "notification" and sub == "show": + cmd_notification_show(flags, positional) + else: + fail("unknown_command", f"fake herdr does not implement: {group} {sub}", exit_code=2) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/test_discovery.py b/tests/integration/test_discovery.py new file mode 100644 index 0000000..28eb899 --- /dev/null +++ b/tests/integration/test_discovery.py @@ -0,0 +1,54 @@ +import subprocess +from pathlib import Path + +from git_workspace.workspace import Workspace +from git_workspace.workspace.core import WorkspaceResolver +from tests.integration.conftest import _GIT_ENV + + +def _up(workspace: Workspace, branch: str) -> Path: + from git_workspace.workspace.service import WorkspaceService + + worktree = WorkspaceService.create(workspace).up(branch, detached=True) + return worktree.dir + + +class TestResolveFromWorktree: + def test_resolves_from_worktree_root(self, workspace: Workspace) -> None: + worktree_dir = _up(workspace, "feature/discovery") + + resolved = WorkspaceResolver.resolve_from_worktree(worktree_dir) + + assert resolved.dir == workspace.dir + + def test_resolves_from_deep_subdirectory(self, workspace: Workspace) -> None: + worktree_dir = _up(workspace, "feature/discovery") + deep = worktree_dir / "a" / "b" + deep.mkdir(parents=True) + + resolved = WorkspaceResolver.resolve_from_worktree(deep) + + assert resolved.dir == workspace.dir + + def test_resolves_worktree_created_outside_workspace_root( + self, workspace: Workspace, tmp_path: Path + ) -> None: + outside = tmp_path / "elsewhere" / "wt" + subprocess.run( + ["git", "worktree", "add", "-b", "feature/outside", str(outside)], + cwd=workspace.dir, + capture_output=True, + env=_GIT_ENV, + check=True, + ) + + resolved = WorkspaceResolver.resolve_from_worktree(outside) + + assert resolved.dir == workspace.dir + + def test_falls_back_to_walk_up_from_inside_config_repo(self, workspace: Workspace) -> None: + # .workspace is itself a git repo; rev-parse there points at the config + # repo's git dir, so discovery must fall back to walking up. + resolved = WorkspaceResolver.resolve_from_worktree(workspace.paths.config) + + assert resolved.dir == workspace.dir diff --git a/tests/integration/test_herdr_backend.py b/tests/integration/test_herdr_backend.py new file mode 100644 index 0000000..290f1a5 --- /dev/null +++ b/tests/integration/test_herdr_backend.py @@ -0,0 +1,169 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from git_workspace.cli.commands.down import down +from git_workspace.cli.commands.remove import remove +from git_workspace.cli.commands.up import up +from git_workspace.workspace import Workspace +from git_workspace.workspace.state import WorkspaceStateStore +from tests.helpers import make_context + +FAKE_HERDR = Path(__file__).parent / "fixtures" / "fake_herdr.py" + + +@pytest.fixture +def herdr_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Points HERDR_BIN_PATH at the fake herdr and returns its state file.""" + state = tmp_path / "fake-herdr-state.json" + monkeypatch.setenv("FAKE_HERDR_STATE", str(state)) + monkeypatch.setenv("HERDR_BIN_PATH", str(FAKE_HERDR)) + return state + + +def herdr_state(state_file: Path) -> dict: + if not state_file.exists(): + return {"workspaces": {}, "focus_log": []} + return json.loads(state_file.read_text()) + + +def workspace_paths(state_file: Path) -> set[str]: + return {info["path"] for info in herdr_state(state_file)["workspaces"].values()} + + +class TestUpWithHerdrBackend: + def test_creates_prepares_and_presents(self, workspace: Workspace, herdr_env: Path) -> None: + up(ctx=make_context(str(workspace.dir)), branch="feat", backend="herdr") + + worktree_dir = (workspace.dir / "feat").resolve() + assert worktree_dir.is_dir() + + state = herdr_state(herdr_env) + assert str(worktree_dir) in workspace_paths(herdr_env) + assert state["focus_log"], "presentation should have been focused" + + record = WorkspaceStateStore(workspace.paths.state).load(worktree_dir) + assert record is not None + assert record.lifecycle_state.value == "ready" + assert record.presentation is not None + assert record.presentation.presenter_kind.value == "herdr" + assert record.presentation.presentation_id in state["focus_log"] + + def test_no_focus_still_opens_presentation(self, workspace: Workspace, herdr_env: Path) -> None: + up(ctx=make_context(str(workspace.dir)), branch="feat", backend="herdr", focus=False) + + assert str((workspace.dir / "feat").resolve()) in workspace_paths(herdr_env) + assert herdr_state(herdr_env)["focus_log"] == [] + + def test_detached_does_not_focus(self, workspace: Workspace, herdr_env: Path) -> None: + up(ctx=make_context(str(workspace.dir)), branch="feat", backend="herdr", detached=True) + + assert herdr_state(herdr_env)["focus_log"] == [] + + def test_native_backend_never_touches_herdr( + self, workspace: Workspace, herdr_env: Path + ) -> None: + up(ctx=make_context(str(workspace.dir)), branch="feat", backend="native") + + assert not herdr_env.exists() + + def test_up_on_existing_worktree_presents_it( + self, workspace: Workspace, herdr_env: Path + ) -> None: + up(ctx=make_context(str(workspace.dir)), branch="feat", backend="native") + assert not herdr_env.exists() + + up(ctx=make_context(str(workspace.dir)), branch="feat", backend="herdr") + + assert str((workspace.dir / "feat").resolve()) in workspace_paths(herdr_env) + + +class TestAutoDetection: + def test_auto_selects_herdr_inside_verified_context( + self, + workspace: Workspace, + herdr_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + socket = tmp_path / "herdr.sock" + socket.touch() + monkeypatch.setenv("HERDR_ENV", "1") + monkeypatch.setenv("HERDR_SOCKET_PATH", str(socket)) + + up(ctx=make_context(str(workspace.dir)), branch="feat") + + assert str((workspace.dir / "feat").resolve()) in workspace_paths(herdr_env) + + def test_defaults_to_native_outside_context( + self, workspace: Workspace, herdr_env: Path + ) -> None: + up(ctx=make_context(str(workspace.dir)), branch="feat") + + assert not herdr_env.exists() + + def test_manifest_native_backend_disables_detection( + self, + workspace: Workspace, + herdr_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + socket = tmp_path / "herdr.sock" + socket.touch() + monkeypatch.setenv("HERDR_ENV", "1") + monkeypatch.setenv("HERDR_SOCKET_PATH", str(socket)) + manifest = workspace.paths.manifest + manifest.write_text(manifest.read_text() + '\n[workspace]\nbackend = "native"\n') + workspace.manifest = type(workspace.manifest).load(workspace) + + up(ctx=make_context(str(workspace.dir)), branch="feat") + + assert not herdr_env.exists() + + +class TestDownAndRemove: + def test_down_closes_workspace_and_preserves_worktree( + self, + workspace: Workspace, + herdr_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + socket = tmp_path / "herdr.sock" + socket.touch() + monkeypatch.setenv("HERDR_ENV", "1") + monkeypatch.setenv("HERDR_SOCKET_PATH", str(socket)) + up(ctx=make_context(str(workspace.dir)), branch="feat") + assert workspace_paths(herdr_env) + + down(ctx=make_context(str(workspace.dir)), branch="feat") + + assert workspace_paths(herdr_env) == set() + assert (workspace.dir / "feat").is_dir() + + def test_rm_removes_worktree_and_workspace_but_preserves_branch( + self, + workspace: Workspace, + herdr_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + socket = tmp_path / "herdr.sock" + socket.touch() + monkeypatch.setenv("HERDR_ENV", "1") + monkeypatch.setenv("HERDR_SOCKET_PATH", str(socket)) + up(ctx=make_context(str(workspace.dir)), branch="feat") + + remove(ctx=make_context(str(workspace.dir)), branch="feat") + + assert workspace_paths(herdr_env) == set() + assert not (workspace.dir / "feat").exists() + result = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", "refs/heads/feat"], + cwd=workspace.dir, + capture_output=True, + ) + assert result.returncode == 0 diff --git a/tests/integration/test_herdr_contracts.py b/tests/integration/test_herdr_contracts.py new file mode 100644 index 0000000..30ac943 --- /dev/null +++ b/tests/integration/test_herdr_contracts.py @@ -0,0 +1,72 @@ +import subprocess +from pathlib import Path + +import pytest + +from git_workspace.presenters.herdr import HerdrPresenter +from git_workspace.providers.herdr import HerdrWorktreeProvider +from git_workspace.subprocesses.runner import SubprocessCommandRunner +from git_workspace.workspace.models import ManagedWorktree, ProviderKind +from tests.contracts.presenter import WorkspacePresenterContract +from tests.contracts.provider import WorktreeProviderContract +from tests.integration.conftest import _GIT_ENV + +FAKE_HERDR = Path(__file__).parent / "fixtures" / "fake_herdr.py" + + +@pytest.fixture +def fake_herdr(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: + monkeypatch.setenv("FAKE_HERDR_STATE", str(tmp_path / "fake-herdr-state.json")) + return str(FAKE_HERDR) + + +@pytest.fixture +def herdr_repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run( + ["git", "init", "-b", "main"], cwd=repo, capture_output=True, env=_GIT_ENV, check=True + ) + (repo / "README.md").write_text("herdr contract fixture\n") + subprocess.run(["git", "add", "."], cwd=repo, capture_output=True, env=_GIT_ENV, check=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=repo, + capture_output=True, + env=_GIT_ENV, + check=True, + ) + return repo + + +class TestHerdrProviderContract(WorktreeProviderContract): + @pytest.fixture + def provider(self, fake_herdr: str) -> HerdrWorktreeProvider: + return HerdrWorktreeProvider(SubprocessCommandRunner(), executable=fake_herdr) + + @pytest.fixture + def repo(self, herdr_repo: Path) -> Path: + return herdr_repo + + +class TestHerdrPresenterContract(WorkspacePresenterContract): + @pytest.fixture + def presenter(self, fake_herdr: str) -> HerdrPresenter: + return HerdrPresenter(SubprocessCommandRunner(), executable=fake_herdr) + + @pytest.fixture + def worktree(self, herdr_repo: Path) -> ManagedWorktree: + worktree_path = herdr_repo / "worktrees" / "presented" + subprocess.run( + ["git", "worktree", "add", "-b", "contract/presented", str(worktree_path)], + cwd=herdr_repo, + capture_output=True, + env=_GIT_ENV, + check=True, + ) + return ManagedWorktree( + repository_path=herdr_repo, + worktree_path=worktree_path, + branch="contract/presented", + provider_kind=ProviderKind.HERDR, + ) diff --git a/tests/integration/test_herdr_plugin.py b/tests/integration/test_herdr_plugin.py new file mode 100644 index 0000000..ac75b45 --- /dev/null +++ b/tests/integration/test_herdr_plugin.py @@ -0,0 +1,286 @@ +import fcntl +import json +import os +import stat +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + +from git_workspace.workspace import Workspace +from git_workspace.workspace.lock import ( + workspace_operation_lock, # noqa: F401 (documents the lock under test) +) +from git_workspace.workspace.state import WorkspaceStateStore, state_file_stem +from tests.integration.conftest import _GIT_ENV + +PLUGIN_ROOT = Path(__file__).parent.parent.parent / "integrations" / "herdr" +HOOK = PLUGIN_ROOT / "hooks" / "prepare" +FAKE_HERDR = Path(__file__).parent / "fixtures" / "fake_herdr.py" + + +@pytest.fixture +def gw_bin(tmp_path: Path) -> str: + """A git-workspace wrapper bound to the project venv's interpreter.""" + wrapper = tmp_path / "git-workspace-wrapper" + wrapper.write_text( + f'#!/bin/sh\nexec "{sys.executable}" -c "from git_workspace import main; main()" "$@"\n' + ) + wrapper.chmod(0o755) + return str(wrapper) + + +def event_json(worktree_path: Path, repo_root: Path, branch: str = "feature/hooked") -> str: + return json.dumps( + { + "event": "worktree_created", + "data": { + "type": "worktree_created", + "workspace": { + "workspace_id": "w1", + "worktree": { + "repo_root": str(repo_root), + "repo_key": str(repo_root / ".git"), + "checkout_path": str(worktree_path), + "is_linked_worktree": True, + }, + }, + "worktree": { + "path": str(worktree_path), + "branch": branch, + "is_linked_worktree": True, + }, + }, + } + ) + + +def run_hook( + *args: str, + gw_bin: str, + env_extra: dict[str, str], + tmp_path: Path, +) -> subprocess.CompletedProcess: + env = { + **os.environ, + "GIT_WORKSPACE_BIN": gw_bin, + "FAKE_HERDR_STATE": str(tmp_path / "fake-herdr-state.json"), + "HERDR_BIN_PATH": str(FAKE_HERDR), + **env_extra, + } + env.pop("HERDR_PLUGIN_EVENT_JSON", None) if "HERDR_PLUGIN_EVENT_JSON" not in env_extra else None + return subprocess.run([str(HOOK), *args], env=env, capture_output=True, text=True) + + +def notifications(tmp_path: Path) -> list[dict]: + state_file = tmp_path / "fake-herdr-state.json" + if not state_file.exists(): + return [] + return json.loads(state_file.read_text()).get("notifications", []) + + +def add_raw_worktree(workspace: Workspace, branch: str = "feature/hooked") -> Path: + worktree_path = workspace.dir / branch + subprocess.run( + ["git", "worktree", "add", "-b", branch, str(worktree_path)], + cwd=workspace.dir, + capture_output=True, + env=_GIT_ENV, + check=True, + ) + return worktree_path.resolve() + + +class TestManifest: + def test_is_valid_and_complete(self) -> None: + manifest = tomllib.loads((PLUGIN_ROOT / "herdr-plugin.toml").read_text()) + + assert manifest["id"] == "git-workspace" + assert manifest["min_herdr_version"] + assert set(manifest["platforms"]) == {"macos", "linux"} + assert {event["on"] for event in manifest["events"]} == { + "worktree.created", + "worktree.opened", + } + + def test_all_commands_point_at_executable_files(self) -> None: + manifest = tomllib.loads((PLUGIN_ROOT / "herdr-plugin.toml").read_text()) + + for entry in [*manifest["events"], *manifest["actions"]]: + script = PLUGIN_ROOT / entry["command"][0] + assert script.is_file(), f"missing hook script: {script}" + assert script.stat().st_mode & stat.S_IXUSR, f"hook not executable: {script}" + + +class TestEventHook: + def test_prepares_worktree_from_created_event( + self, workspace_with_hooks: Workspace, gw_bin: str, tmp_path: Path + ) -> None: + worktree_path = add_raw_worktree(workspace_with_hooks) + + result = run_hook( + gw_bin=gw_bin, + env_extra={ + "HERDR_PLUGIN_EVENT_JSON": event_json(worktree_path, workspace_with_hooks.dir) + }, + tmp_path=tmp_path, + ) + + assert result.returncode == 0, result.stderr + assert (workspace_with_hooks.dir / ".hook-on-setup").exists() + record = WorkspaceStateStore(workspace_with_hooks.paths.state).load(worktree_path) + assert record is not None + assert record.lifecycle_state.value == "ready" + + def test_skips_repositories_without_git_workspace_config( + self, gw_bin: str, tmp_path: Path + ) -> None: + plain_repo = tmp_path / "plain" + plain_repo.mkdir() + subprocess.run( + ["git", "init", "-b", "main"], cwd=plain_repo, capture_output=True, check=True + ) + + result = run_hook( + gw_bin=gw_bin, + env_extra={"HERDR_PLUGIN_EVENT_JSON": event_json(plain_repo / "wt", plain_repo)}, + tmp_path=tmp_path, + ) + + assert result.returncode == 0 + assert "skipping" in result.stdout + assert notifications(tmp_path) == [] + + def test_missing_worktree_path_is_a_noop(self, gw_bin: str, tmp_path: Path) -> None: + result = run_hook( + gw_bin=gw_bin, + env_extra={"HERDR_PLUGIN_EVENT_JSON": json.dumps({"event": "x", "data": {}})}, + tmp_path=tmp_path, + ) + + assert result.returncode == 0 + assert "nothing to prepare" in result.stdout + + def test_failure_sends_notification_with_retry_hint( + self, workspace_with_hooks: Workspace, gw_bin: str, tmp_path: Path + ) -> None: + worktree_path = add_raw_worktree(workspace_with_hooks) + hook = workspace_with_hooks.paths.bin / "on_setup" + hook.write_text("#!/bin/sh\nexit 1\n") + hook.chmod(0o755) + + result = run_hook( + gw_bin=gw_bin, + env_extra={ + "HERDR_PLUGIN_EVENT_JSON": event_json(worktree_path, workspace_with_hooks.dir) + }, + tmp_path=tmp_path, + ) + + assert result.returncode != 0 + sent = notifications(tmp_path) + assert len(sent) == 1 + assert "prepare --force" in sent[0]["body"] + + def test_lock_contention_is_benign( + self, workspace_with_hooks: Workspace, gw_bin: str, tmp_path: Path + ) -> None: + worktree_path = add_raw_worktree(workspace_with_hooks) + locks_dir = workspace_with_hooks.paths.state / "locks" + locks_dir.mkdir(parents=True, exist_ok=True) + lock_file = locks_dir / f"{state_file_stem(worktree_path)}.lock" + + fd = os.open(lock_file, os.O_CREAT | os.O_RDWR) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + try: + result = run_hook( + gw_bin=gw_bin, + env_extra={ + "HERDR_PLUGIN_EVENT_JSON": event_json(worktree_path, workspace_with_hooks.dir) + }, + tmp_path=tmp_path, + ) + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + assert result.returncode == 0 + assert "skipping" in result.stdout + assert notifications(tmp_path) == [] + + def test_missing_git_workspace_binary_notifies_and_fails( + self, workspace_with_hooks: Workspace, tmp_path: Path + ) -> None: + worktree_path = add_raw_worktree(workspace_with_hooks) + # A PATH with python3 (for the hook's own shebang) but no git-workspace. + bare_bin = tmp_path / "bare-bin" + bare_bin.mkdir() + (bare_bin / "python3").symlink_to(sys.executable) + + result = run_hook( + gw_bin="", + env_extra={ + "GIT_WORKSPACE_BIN": "", + "PATH": str(bare_bin), + "HERDR_PLUGIN_EVENT_JSON": event_json(worktree_path, workspace_with_hooks.dir), + }, + tmp_path=tmp_path, + ) + + assert result.returncode == 1 + assert notifications(tmp_path) + + +class TestActionMode: + def test_prepares_worktree_from_invocation_context( + self, workspace_with_hooks: Workspace, gw_bin: str, tmp_path: Path + ) -> None: + worktree_path = add_raw_worktree(workspace_with_hooks) + context = json.dumps( + { + "workspace_id": "w1", + "workspace_cwd": str(worktree_path), + "worktree": { + "repo_root": str(workspace_with_hooks.dir), + "checkout_path": str(worktree_path), + }, + } + ) + + result = run_hook( + gw_bin=gw_bin, + env_extra={"HERDR_PLUGIN_CONTEXT_JSON": context}, + tmp_path=tmp_path, + ) + + assert result.returncode == 0, result.stderr + record = WorkspaceStateStore(workspace_with_hooks.paths.state).load(worktree_path) + assert record is not None + assert record.lifecycle_state.value == "ready" + + def test_force_flag_reruns_preparation( + self, workspace_with_hooks: Workspace, gw_bin: str, tmp_path: Path + ) -> None: + worktree_path = add_raw_worktree(workspace_with_hooks) + context = json.dumps( + { + "worktree": { + "repo_root": str(workspace_with_hooks.dir), + "checkout_path": str(worktree_path), + } + } + ) + env = {"HERDR_PLUGIN_CONTEXT_JSON": context} + + run_hook(gw_bin=gw_bin, env_extra=env, tmp_path=tmp_path) + marker = workspace_with_hooks.dir / ".hook-on-setup" + marker.unlink() + + run_hook(gw_bin=gw_bin, env_extra=env, tmp_path=tmp_path) + assert not marker.exists(), "plain prepare should no-op when READY" + + result = run_hook(gw_bin=gw_bin, env_extra=env, tmp_path=tmp_path, *("--force",)) + assert result.returncode == 0 + assert marker.exists(), "--force should re-run setup hooks" diff --git a/tests/integration/test_hooks.py b/tests/integration/test_hooks.py index 95590d5..f404aeb 100644 --- a/tests/integration/test_hooks.py +++ b/tests/integration/test_hooks.py @@ -80,8 +80,8 @@ def test_on_detach_runs_before_on_teardown_on_remove(workspace_with_hooks: Works assert (workspace_with_hooks.dir / ".hook-on-teardown").exists() -def test_hooks_do_not_run_on_prune(workspace_with_hooks: Workspace) -> None: +def test_hooks_run_on_prune(workspace_with_hooks: Workspace) -> None: up(ctx=make_context(str(workspace_with_hooks.dir)), branch="feat") prune(ctx=make_context(str(workspace_with_hooks.dir)), older_than_days=-1, dry_run=False) - assert not (workspace_with_hooks.dir / ".hook-on-detach").exists() - assert not (workspace_with_hooks.dir / ".hook-on-teardown").exists() + assert (workspace_with_hooks.dir / ".hook-on-detach").exists() + assert (workspace_with_hooks.dir / ".hook-on-teardown").exists() diff --git a/tests/integration/test_linking.py b/tests/integration/test_linking.py index ecb4392..ed00e27 100644 --- a/tests/integration/test_linking.py +++ b/tests/integration/test_linking.py @@ -2,7 +2,7 @@ from git_workspace.cli.commands.reset import reset from git_workspace.cli.commands.up import up -from git_workspace.errors import WorkspaceLinkError +from git_workspace.errors import WorkspacePreparationError from git_workspace.workspace import Workspace from tests.helpers import make_context @@ -84,7 +84,7 @@ def test_cannot_link_to_existing_file(workspace_with_links: Workspace) -> None: link = workspace_with_links.dir / "main" / ".dotfile" link.unlink() link.write_text("i am a real file") - with pytest.raises(WorkspaceLinkError): + with pytest.raises(WorkspacePreparationError): reset(ctx=make_context(str(workspace_with_links.dir)), branch="main") @@ -97,5 +97,5 @@ def test_cannot_link_to_symlink_pointing_elsewhere( other = workspace_with_links.dir / "other-target" other.write_text("other") link.symlink_to(other) - with pytest.raises(WorkspaceLinkError): + with pytest.raises(WorkspacePreparationError): reset(ctx=make_context(str(workspace_with_links.dir)), branch="main") diff --git a/tests/integration/test_native_git_provider_contract.py b/tests/integration/test_native_git_provider_contract.py new file mode 100644 index 0000000..8b84b95 --- /dev/null +++ b/tests/integration/test_native_git_provider_contract.py @@ -0,0 +1,33 @@ +import subprocess +from pathlib import Path + +import pytest + +from git_workspace.providers.native_git import NativeGitProvider +from git_workspace.subprocesses.runner import SubprocessCommandRunner +from tests.contracts.provider import WorktreeProviderContract +from tests.integration.conftest import _GIT_ENV + + +class TestNativeGitProviderContract(WorktreeProviderContract): + @pytest.fixture + def provider(self) -> NativeGitProvider: + return NativeGitProvider(SubprocessCommandRunner()) + + @pytest.fixture + def repo(self, tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run( + ["git", "init", "-b", "main"], cwd=repo, capture_output=True, env=_GIT_ENV, check=True + ) + (repo / "README.md").write_text("contract fixture\n") + subprocess.run(["git", "add", "."], cwd=repo, capture_output=True, env=_GIT_ENV, check=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=repo, + capture_output=True, + env=_GIT_ENV, + check=True, + ) + return repo diff --git a/tests/integration/test_prepare.py b/tests/integration/test_prepare.py new file mode 100644 index 0000000..4e7e3e3 --- /dev/null +++ b/tests/integration/test_prepare.py @@ -0,0 +1,113 @@ +import subprocess +from pathlib import Path + +import pytest + +from git_workspace.cli.commands.prepare import prepare +from git_workspace.cli.commands.up import up +from git_workspace.errors import WorkspacePreparationError, WorktreeNotFoundError +from git_workspace.workspace import Workspace +from git_workspace.workspace.state import WorkspaceStateStore +from tests.helpers import make_context +from tests.integration.conftest import _GIT_ENV + + +def _store(workspace: Workspace) -> WorkspaceStateStore: + return WorkspaceStateStore(workspace.paths.state) + + +def test_prepares_worktree_created_by_up(workspace_with_hooks: Workspace) -> None: + up(ctx=make_context(str(workspace_with_hooks.dir)), branch="feat", detached=True) + hook_marker = workspace_with_hooks.dir / ".hook-on-setup" + hook_marker.unlink() + + prepare( + ctx=make_context(), + path=str(workspace_with_hooks.dir / "feat"), + force=True, + ) + + assert hook_marker.exists() + + +def test_prepares_raw_git_worktree_at_arbitrary_path( + workspace_with_hooks: Workspace, tmp_path: Path +) -> None: + # A worktree created directly with git, outside the workspace root and + # without git-workspace involvement — the Herdr/external-host scenario. + outside = tmp_path / "elsewhere" / "raw-wt" + subprocess.run( + ["git", "worktree", "add", "-b", "feature/raw", str(outside)], + cwd=workspace_with_hooks.dir, + capture_output=True, + env=_GIT_ENV, + check=True, + ) + + prepare(ctx=make_context(), path=str(outside)) + + assert (workspace_with_hooks.dir / ".hook-on-setup").exists() + record = _store(workspace_with_hooks).load(outside) + assert record is not None + assert record.lifecycle_state.value == "ready" + + +def test_prepare_is_a_noop_when_already_ready(workspace_with_hooks: Workspace) -> None: + up(ctx=make_context(str(workspace_with_hooks.dir)), branch="feat", detached=True) + hook_marker = workspace_with_hooks.dir / ".hook-on-setup" + hook_marker.unlink() + + prepare(ctx=make_context(), path=str(workspace_with_hooks.dir / "feat")) + + assert not hook_marker.exists() + + +def test_prepare_records_ready_state(workspace: Workspace) -> None: + up(ctx=make_context(str(workspace.dir)), branch="main", detached=True) + + record = _store(workspace).load(workspace.dir / "main") + + assert record is not None + assert record.lifecycle_state.value == "ready" + assert record.worktree.branch == "main" + assert record.presentation is None + + +def test_prepare_rejects_non_worktree_path(workspace: Workspace, tmp_path: Path) -> None: + not_a_worktree = tmp_path / "plain" + not_a_worktree.mkdir() + + with pytest.raises(WorktreeNotFoundError): + prepare(ctx=make_context(str(workspace.dir)), path=str(not_a_worktree)) + + +def test_failed_preparation_records_state_and_is_retryable( + workspace_with_hooks: Workspace, +) -> None: + up(ctx=make_context(str(workspace_with_hooks.dir)), branch="feat", detached=True) + worktree_dir = workspace_with_hooks.dir / "feat" + + # Sabotage the setup hook so preparation fails. + hook = workspace_with_hooks.paths.bin / "on_setup" + original = hook.read_bytes() + hook.write_text("#!/bin/sh\nexit 1\n") + hook.chmod(0o755) + + with pytest.raises(WorkspacePreparationError): + prepare(ctx=make_context(), path=str(worktree_dir), force=True) + + record = _store(workspace_with_hooks).load(worktree_dir) + assert record is not None + assert record.lifecycle_state.value == "preparation-failed" + assert record.preparation_error + assert worktree_dir.is_dir() + + # Repair the hook and retry — prepare must succeed and mark READY. + hook.write_bytes(original) + hook.chmod(0o755) + + prepare(ctx=make_context(), path=str(worktree_dir)) + + record = _store(workspace_with_hooks).load(worktree_dir) + assert record is not None + assert record.lifecycle_state.value == "ready" diff --git a/tests/integration/test_remove.py b/tests/integration/test_remove.py index aba380e..4d752fb 100644 --- a/tests/integration/test_remove.py +++ b/tests/integration/test_remove.py @@ -31,3 +31,32 @@ def test_worktree_can_be_recreated_after_remove(workspace: Workspace) -> None: remove(ctx=make_context(str(workspace.dir)), branch="main") up(ctx=make_context(str(workspace.dir)), branch="main") assert (workspace.dir / "main").is_dir() + + +def test_remove_deletes_state_file(workspace: Workspace) -> None: + from git_workspace.cli.commands.up import up + from git_workspace.workspace.state import WorkspaceStateStore + + up(ctx=make_context(str(workspace.dir)), branch="main") + store = WorkspaceStateStore(workspace.paths.state) + assert store.load(workspace.dir / "main") is not None + + remove(ctx=make_context(str(workspace.dir)), branch="main") + + assert store.load(workspace.dir / "main") is None + + +def test_remove_preserves_branch(workspace: Workspace) -> None: + import subprocess + + from git_workspace.cli.commands.up import up + + up(ctx=make_context(str(workspace.dir)), branch="feature/keep") + remove(ctx=make_context(str(workspace.dir)), branch="feature/keep") + + result = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", "refs/heads/feature/keep"], + cwd=workspace.dir, + capture_output=True, + ) + assert result.returncode == 0 diff --git a/tests/integration/test_up.py b/tests/integration/test_up.py index d1e8f4a..fad1e33 100644 --- a/tests/integration/test_up.py +++ b/tests/integration/test_up.py @@ -114,3 +114,27 @@ def test_new_branch_forks_from_latest_remote_main(setup: Setup, tmp_path: Path) assert worktree_path.is_dir() # The file added to remote main after cloning must be present in the new branch assert (worktree_path / "new_remote_file.txt").exists() + + +def test_up_records_ready_state(workspace: Workspace) -> None: + from git_workspace.workspace.state import WorkspaceStateStore + + up(ctx=make_context(str(workspace.dir)), branch="main") + + record = WorkspaceStateStore(workspace.paths.state).load(workspace.dir / "main") + assert record is not None + assert record.lifecycle_state.value == "ready" + + +def test_up_works_for_legacy_worktree_without_state(workspace: Workspace) -> None: + from git_workspace.workspace.state import WorkspaceStateStore + + up(ctx=make_context(str(workspace.dir)), branch="main") + store = WorkspaceStateStore(workspace.paths.state) + store.delete(workspace.dir / "main") + + up(ctx=make_context(str(workspace.dir)), branch="main") + + record = store.load(workspace.dir / "main") + assert record is not None + assert record.lifecycle_state.value == "ready" diff --git a/tests/unit/test_backend_resolver.py b/tests/unit/test_backend_resolver.py new file mode 100644 index 0000000..71d2bd1 --- /dev/null +++ b/tests/unit/test_backend_resolver.py @@ -0,0 +1,144 @@ +from pathlib import Path + +import pytest +from pytest_mock import MockerFixture + +from git_workspace.backends.resolver import WorkspaceBackendResolver +from git_workspace.errors import InvalidInputError, ProviderUnavailableError +from git_workspace.manifest import WorkspaceSettings +from git_workspace.presenters.herdr import HerdrPresenter +from git_workspace.providers.herdr import HerdrWorktreeProvider +from git_workspace.providers.native_git import NativeGitProvider +from git_workspace.workspace.models import PresenterKind, ProviderKind + + +@pytest.fixture +def herdr_installed(mocker: MockerFixture) -> None: + mocker.patch("git_workspace.subprocesses.herdr.shutil.which", return_value="/usr/bin/herdr") + + +@pytest.fixture +def herdr_missing(mocker: MockerFixture) -> None: + mocker.patch("git_workspace.subprocesses.herdr.shutil.which", return_value=None) + + +@pytest.fixture +def herdr_context(monkeypatch, tmp_path: Path, herdr_installed: None) -> None: + socket = tmp_path / "herdr.sock" + socket.touch() + monkeypatch.setenv("HERDR_ENV", "1") + monkeypatch.setenv("HERDR_SOCKET_PATH", str(socket)) + + +class TestDefaults: + def test_resolves_native_outside_herdr_context(self, herdr_installed: None) -> None: + backend = WorkspaceBackendResolver().resolve() + + assert backend.name == "native" + assert isinstance(backend.provider, NativeGitProvider) + assert backend.presenter is None + + def test_auto_detects_herdr_inside_verified_context(self, herdr_context: None) -> None: + backend = WorkspaceBackendResolver().resolve() + + assert backend.name == "herdr" + assert isinstance(backend.provider, HerdrWorktreeProvider) + assert isinstance(backend.presenter, HerdrPresenter) + + def test_executable_alone_is_not_enough_for_auto(self, herdr_installed: None) -> None: + # herdr installed but no verified context markers → native. + backend = WorkspaceBackendResolver().resolve() + + assert backend.name == "native" + + def test_env_marker_without_socket_is_not_verified( + self, monkeypatch, tmp_path: Path, herdr_installed: None + ) -> None: + monkeypatch.setenv("HERDR_ENV", "1") + monkeypatch.setenv("HERDR_SOCKET_PATH", str(tmp_path / "missing.sock")) + + assert WorkspaceBackendResolver().resolve().name == "native" + + +class TestExplicitSelection: + def test_backend_name_native(self, herdr_context: None) -> None: + backend = WorkspaceBackendResolver().resolve(backend_name="native") + + assert backend.name == "native" + assert backend.presenter is None + + def test_backend_name_herdr(self, herdr_installed: None) -> None: + backend = WorkspaceBackendResolver().resolve(backend_name="herdr") + + assert isinstance(backend.provider, HerdrWorktreeProvider) + assert isinstance(backend.presenter, HerdrPresenter) + + def test_explicit_herdr_without_executable_raises(self, herdr_missing: None) -> None: + with pytest.raises(ProviderUnavailableError): + WorkspaceBackendResolver().resolve(backend_name="herdr") + + def test_unknown_backend_name_raises(self) -> None: + with pytest.raises(InvalidInputError): + WorkspaceBackendResolver().resolve(backend_name="vscode") + + def test_explicit_kinds_compose_across_preset(self, herdr_installed: None) -> None: + backend = WorkspaceBackendResolver().resolve( + backend_name="herdr", + presenter_kind=PresenterKind.NONE, + ) + + assert isinstance(backend.provider, HerdrWorktreeProvider) + assert backend.presenter is None + + def test_explicit_provider_overrides_preset(self, herdr_installed: None) -> None: + backend = WorkspaceBackendResolver().resolve( + backend_name="herdr", + provider_kind=ProviderKind.NATIVE_GIT, + ) + + assert isinstance(backend.provider, NativeGitProvider) + assert isinstance(backend.presenter, HerdrPresenter) + + +class TestManifestSettings: + def test_config_backend_preset(self, herdr_installed: None) -> None: + backend = WorkspaceBackendResolver().resolve(settings=WorkspaceSettings(backend="herdr")) + + assert isinstance(backend.provider, HerdrWorktreeProvider) + + def test_config_native_disables_auto_detection(self, herdr_context: None) -> None: + backend = WorkspaceBackendResolver().resolve(settings=WorkspaceSettings(backend="native")) + + assert backend.name == "native" + + def test_config_member_overrides_apply(self, herdr_installed: None) -> None: + backend = WorkspaceBackendResolver().resolve( + settings=WorkspaceSettings(backend="herdr", presenter="none") + ) + + assert isinstance(backend.provider, HerdrWorktreeProvider) + assert backend.presenter is None + + def test_cli_backend_suppresses_config_member_overrides(self, herdr_installed: None) -> None: + backend = WorkspaceBackendResolver().resolve( + backend_name="herdr", + settings=WorkspaceSettings(presenter="none"), + ) + + assert isinstance(backend.presenter, HerdrPresenter) + + def test_cli_kinds_beat_config_members(self, herdr_installed: None) -> None: + backend = WorkspaceBackendResolver().resolve( + presenter_kind=PresenterKind.HERDR, + settings=WorkspaceSettings(backend="native", presenter="none"), + ) + + assert isinstance(backend.presenter, HerdrPresenter) + + def test_unknown_config_provider_raises(self) -> None: + with pytest.raises(InvalidInputError): + WorkspaceBackendResolver().resolve(settings=WorkspaceSettings(provider="subversion")) + + def test_unknown_config_presenter_raises(self) -> None: + with pytest.raises(InvalidInputError): + WorkspaceBackendResolver().resolve(settings=WorkspaceSettings(presenter="vscode")) diff --git a/tests/unit/test_command_down.py b/tests/unit/test_command_down.py index 26d9908..d5d2337 100644 --- a/tests/unit/test_command_down.py +++ b/tests/unit/test_command_down.py @@ -13,12 +13,19 @@ @pytest.fixture(autouse=True) def mock_workspace_resolve(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.cli.commands.down.Workspace.resolve") + mock = mocker.patch("git_workspace.cli.commands.down.Workspace.resolve") + mock.return_value.resolve_worktree.return_value.branch = BRANCH + return mock @pytest.fixture(autouse=True) -def mock_deactivate_worktree(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.cli.commands.down.operations.deactivate_worktree") +def mock_service_create(mocker: MockerFixture) -> MagicMock: + return mocker.patch("git_workspace.cli.commands.down.WorkspaceService.create") + + +@pytest.fixture +def mock_service(mock_service_create: MagicMock) -> MagicMock: + return mock_service_create.return_value class TestDown: @@ -30,44 +37,18 @@ def test_resolves_worktree(self, mock_workspace_resolve: MagicMock) -> None: down(ctx=make_context(), branch=BRANCH) mock_workspace_resolve.return_value.resolve_worktree.assert_called_once_with(BRANCH) - def test_deactivates_worktree_with_runtime_vars( - self, - mock_workspace_resolve: MagicMock, - mock_deactivate_worktree: MagicMock, - ) -> None: - down(ctx=make_context(), runtime_vars=RUNTIME_VARS) # ty:ignore[invalid-argument-type] - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_worktree.return_value - mock_deactivate_worktree.assert_called_once_with( - worktree, - runtime_vars={"MY_VAR": "my_value"}, - effective_branch=None, - ) - - def test_deactivates_worktree_with_empty_runtime_vars_when_none( - self, - mock_workspace_resolve: MagicMock, - mock_deactivate_worktree: MagicMock, - ) -> None: - down(ctx=make_context()) - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_worktree.return_value - mock_deactivate_worktree.assert_called_once_with( - worktree, + def test_delegates_to_service_down(self, mock_service: MagicMock) -> None: + down(ctx=make_context(), branch=BRANCH) + mock_service.down.assert_called_once_with( + BRANCH, runtime_vars={}, effective_branch=None, ) - def test_passes_effective_branch_to_deactivate( - self, - mock_workspace_resolve: MagicMock, - mock_deactivate_worktree: MagicMock, - ) -> None: + def test_passes_runtime_vars(self, mock_service: MagicMock) -> None: + down(ctx=make_context(), runtime_vars=RUNTIME_VARS) # ty:ignore[invalid-argument-type] + assert mock_service.down.call_args.kwargs["runtime_vars"] == {"MY_VAR": "my_value"} + + def test_passes_effective_branch(self, mock_service: MagicMock) -> None: down(ctx=make_context(), effective_branch="gabriel/impersonated") - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_worktree.return_value - mock_deactivate_worktree.assert_called_once_with( - worktree, - runtime_vars={}, - effective_branch="gabriel/impersonated", - ) + assert mock_service.down.call_args.kwargs["effective_branch"] == "gabriel/impersonated" diff --git a/tests/unit/test_command_prepare.py b/tests/unit/test_command_prepare.py new file mode 100644 index 0000000..a21e1bb --- /dev/null +++ b/tests/unit/test_command_prepare.py @@ -0,0 +1,93 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from git_workspace.cli.commands.prepare import prepare +from git_workspace.workspace.service import PrepareOutcome +from tests.helpers import make_context + +WORKSPACE_DIR = "/workspace" +RUNTIME_VARS: list[tuple[str, str]] = [("MY_VAR", "my_value")] + + +@pytest.fixture(autouse=True) +def mock_workspace_resolve(mocker: MockerFixture) -> MagicMock: + return mocker.patch("git_workspace.cli.commands.prepare.Workspace.resolve") + + +@pytest.fixture(autouse=True) +def mock_resolve_from_worktree(mocker: MockerFixture) -> MagicMock: + return mocker.patch( + "git_workspace.cli.commands.prepare.WorkspaceResolver.resolve_from_worktree" + ) + + +@pytest.fixture(autouse=True) +def mock_service_create(mocker: MockerFixture) -> MagicMock: + mock = mocker.patch("git_workspace.cli.commands.prepare.WorkspaceService.create") + mock.return_value.prepare_path.return_value = PrepareOutcome(record=MagicMock(), skipped=False) + return mock + + +@pytest.fixture +def mock_service(mock_service_create: MagicMock) -> MagicMock: + return mock_service_create.return_value + + +class TestPrepare: + def test_discovers_workspace_from_target_path( + self, tmp_path: Path, mock_resolve_from_worktree: MagicMock + ) -> None: + prepare(ctx=make_context(), path=str(tmp_path)) + + mock_resolve_from_worktree.assert_called_once_with(tmp_path.resolve()) + + def test_explicit_root_overrides_discovery( + self, mock_workspace_resolve: MagicMock, mock_resolve_from_worktree: MagicMock + ) -> None: + prepare(ctx=make_context(WORKSPACE_DIR), path="/somewhere") + + mock_workspace_resolve.assert_called_once_with(WORKSPACE_DIR) + mock_resolve_from_worktree.assert_not_called() + + def test_defaults_to_cwd( + self, mocker: MockerFixture, mock_resolve_from_worktree: MagicMock + ) -> None: + cwd = Path("/current/dir") + mocker.patch("git_workspace.cli.commands.prepare.Path.cwd", return_value=cwd) + + prepare(ctx=make_context()) + + mock_resolve_from_worktree.assert_called_once_with(cwd) + + def test_delegates_to_service_prepare_path( + self, tmp_path: Path, mock_service: MagicMock + ) -> None: + prepare(ctx=make_context(), path=str(tmp_path)) + + mock_service.prepare_path.assert_called_once_with( + tmp_path.resolve(), + force=False, + runtime_vars={}, + effective_branch=None, + ) + + def test_passes_force(self, tmp_path: Path, mock_service: MagicMock) -> None: + prepare(ctx=make_context(), path=str(tmp_path), force=True) + + assert mock_service.prepare_path.call_args.kwargs["force"] is True + + def test_passes_runtime_vars_and_effective_branch( + self, tmp_path: Path, mock_service: MagicMock + ) -> None: + prepare( + ctx=make_context(), + path=str(tmp_path), + runtime_vars=RUNTIME_VARS, # ty:ignore[invalid-argument-type] + effective_branch="release/x", + ) + + assert mock_service.prepare_path.call_args.kwargs["runtime_vars"] == {"MY_VAR": "my_value"} + assert mock_service.prepare_path.call_args.kwargs["effective_branch"] == "release/x" diff --git a/tests/unit/test_command_prune.py b/tests/unit/test_command_prune.py index 8eab072..b3d59d8 100644 --- a/tests/unit/test_command_prune.py +++ b/tests/unit/test_command_prune.py @@ -5,7 +5,8 @@ from pytest_mock import MockerFixture from git_workspace.cli.commands.prune import prune -from git_workspace.errors import UnableToResolveWorkspaceError +from git_workspace.errors import UnableToResolveWorkspaceError, WorkspaceLockedError +from git_workspace.workspace.service import PruneFailure from tests.helpers import make_context WORKSPACE_DIR = "/workspace" @@ -14,11 +15,23 @@ @pytest.fixture(autouse=True) def mock_workspace_resolve(mocker: MockerFixture) -> MagicMock: mock = mocker.patch("git_workspace.cli.commands.prune.Workspace.resolve") - mock.return_value.manifest.prune = None - mock.return_value.list_worktrees.return_value = [] + mock.return_value.manifest.prune = MagicMock() return mock +@pytest.fixture(autouse=True) +def mock_service_create(mocker: MockerFixture) -> MagicMock: + mock = mocker.patch("git_workspace.cli.commands.prune.WorkspaceService.create") + mock.return_value.prune_candidates.return_value = [] + mock.return_value.prune.return_value = [] + return mock + + +@pytest.fixture +def mock_service(mock_service_create: MagicMock) -> MagicMock: + return mock_service_create.return_value + + def make_worktree(branch: str = "feature/old", age_days: int = 60) -> MagicMock: wt = MagicMock() wt.branch = branch @@ -43,76 +56,38 @@ def test_raises_bad_parameter_when_no_threshold_and_no_manifest_prune( with pytest.raises(typer.BadParameter): prune(ctx=make_context()) - def test_uses_older_than_days_arg_as_threshold(self, mock_workspace_resolve: MagicMock) -> None: - within_threshold = make_worktree(age_days=5) - beyond_threshold = make_worktree(age_days=60) - mock_workspace_resolve.return_value.list_worktrees.return_value = [ - within_threshold, - beyond_threshold, - ] - - prune(ctx=make_context(), older_than_days=30, dry_run=False) + def test_passes_threshold_to_service(self, mock_service: MagicMock) -> None: + prune(ctx=make_context(), older_than_days=30) + mock_service.prune_candidates.assert_called_once_with(older_than_days=30) - within_threshold.delete.assert_not_called() - beyond_threshold.delete.assert_called_once_with(force=True) + def test_dry_run_does_not_prune(self, mock_service: MagicMock) -> None: + mock_service.prune_candidates.return_value = [make_worktree()] - def test_uses_manifest_prune_threshold_when_arg_not_provided( - self, mock_workspace_resolve: MagicMock - ) -> None: - prune_config = MagicMock() - prune_config.older_than_days = 30 - prune_config.exclude_branches = [] - mock_workspace_resolve.return_value.manifest.prune = prune_config - old_wt = make_worktree(age_days=60) - mock_workspace_resolve.return_value.list_worktrees.return_value = [old_wt] + prune(ctx=make_context(), older_than_days=30, dry_run=True) - prune(ctx=make_context(), dry_run=False) + mock_service.prune.assert_not_called() - old_wt.delete.assert_called_once_with(force=True) + def test_apply_prunes_candidates(self, mock_service: MagicMock) -> None: + candidates = [make_worktree("feature/a"), make_worktree("feature/b")] + mock_service.prune_candidates.return_value = candidates - def test_older_than_days_arg_takes_precedence_over_manifest( - self, mock_workspace_resolve: MagicMock - ) -> None: - prune_config = MagicMock() - prune_config.older_than_days = 90 - prune_config.exclude_branches = [] - mock_workspace_resolve.return_value.manifest.prune = prune_config - wt = make_worktree(age_days=60) - mock_workspace_resolve.return_value.list_worktrees.return_value = [wt] - - # manifest threshold=90 would exclude wt (age=60), but arg threshold=30 includes it prune(ctx=make_context(), older_than_days=30, dry_run=False) - wt.delete.assert_called_once_with(force=True) - - def test_excludes_protected_branches_from_candidates( - self, mock_workspace_resolve: MagicMock - ) -> None: - prune_config = MagicMock() - prune_config.older_than_days = 30 - prune_config.exclude_branches = ["main"] - mock_workspace_resolve.return_value.manifest.prune = prune_config - protected_wt = make_worktree(branch="main", age_days=60) - mock_workspace_resolve.return_value.list_worktrees.return_value = [protected_wt] - - prune(ctx=make_context(), dry_run=False) + mock_service.prune.assert_called_once_with(candidates) - protected_wt.delete.assert_not_called() - - def test_dry_run_does_not_delete_worktrees(self, mock_workspace_resolve: MagicMock) -> None: - old_wt = make_worktree(age_days=60) - mock_workspace_resolve.return_value.list_worktrees.return_value = [old_wt] - - prune(ctx=make_context(), older_than_days=30, dry_run=True) + def test_does_not_prune_when_no_candidates(self, mock_service: MagicMock) -> None: + prune(ctx=make_context(), older_than_days=30, dry_run=False) - old_wt.delete.assert_not_called() + mock_service.prune.assert_not_called() - def test_apply_deletes_each_candidate(self, mock_workspace_resolve: MagicMock) -> None: - wt1 = make_worktree(branch="feature/a", age_days=60) - wt2 = make_worktree(branch="feature/b", age_days=60) - mock_workspace_resolve.return_value.list_worktrees.return_value = [wt1, wt2] + def test_exits_non_zero_when_failures_reported(self, mock_service: MagicMock) -> None: + worktree = make_worktree() + mock_service.prune_candidates.return_value = [worktree] + mock_service.prune.return_value = [ + PruneFailure(worktree=worktree, error=WorkspaceLockedError("locked")) + ] - prune(ctx=make_context(), older_than_days=30, dry_run=False) + with pytest.raises(typer.Exit) as excinfo: + prune(ctx=make_context(), older_than_days=30, dry_run=False) - wt1.delete.assert_called_once_with(force=True) - wt2.delete.assert_called_once_with(force=True) + assert excinfo.value.exit_code == 1 diff --git a/tests/unit/test_command_remove.py b/tests/unit/test_command_remove.py index 959c411..62586f9 100644 --- a/tests/unit/test_command_remove.py +++ b/tests/unit/test_command_remove.py @@ -13,12 +13,19 @@ @pytest.fixture(autouse=True) def mock_workspace_resolve(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.cli.commands.remove.Workspace.resolve") + mock = mocker.patch("git_workspace.cli.commands.remove.Workspace.resolve") + mock.return_value.resolve_worktree.return_value.branch = BRANCH + return mock @pytest.fixture(autouse=True) -def mock_remove_worktree(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.cli.commands.remove.operations.remove_worktree") +def mock_service_create(mocker: MockerFixture) -> MagicMock: + return mocker.patch("git_workspace.cli.commands.remove.WorkspaceService.create") + + +@pytest.fixture +def mock_service(mock_service_create: MagicMock) -> MagicMock: + return mock_service_create.return_value class TestRemove: @@ -30,62 +37,23 @@ def test_resolves_worktree(self, mock_workspace_resolve: MagicMock) -> None: remove(ctx=make_context(), branch=BRANCH) mock_workspace_resolve.return_value.resolve_worktree.assert_called_once_with(BRANCH) - def test_removes_worktree_with_runtime_vars( - self, - mock_workspace_resolve: MagicMock, - mock_remove_worktree: MagicMock, - ) -> None: - remove(ctx=make_context(), runtime_vars=RUNTIME_VARS) # ty:ignore[invalid-argument-type] - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_worktree.return_value - mock_remove_worktree.assert_called_once_with( - worktree, - runtime_vars={"MY_VAR": "my_value"}, + def test_delegates_to_service_remove(self, mock_service: MagicMock) -> None: + remove(ctx=make_context(), branch=BRANCH) + mock_service.remove.assert_called_once_with( + BRANCH, force=False, - effective_branch=None, - ) - - def test_removes_worktree_with_empty_runtime_vars_when_none( - self, - mock_workspace_resolve: MagicMock, - mock_remove_worktree: MagicMock, - ) -> None: - remove(ctx=make_context()) - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_worktree.return_value - mock_remove_worktree.assert_called_once_with( - worktree, runtime_vars={}, - force=False, effective_branch=None, ) - def test_removes_worktree_with_force( - self, - mock_workspace_resolve: MagicMock, - mock_remove_worktree: MagicMock, - ) -> None: + def test_passes_force(self, mock_service: MagicMock) -> None: remove(ctx=make_context(), force=True) - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_worktree.return_value - mock_remove_worktree.assert_called_once_with( - worktree, - runtime_vars={}, - force=True, - effective_branch=None, - ) + assert mock_service.remove.call_args.kwargs["force"] is True + + def test_passes_runtime_vars(self, mock_service: MagicMock) -> None: + remove(ctx=make_context(), runtime_vars=RUNTIME_VARS) # ty:ignore[invalid-argument-type] + assert mock_service.remove.call_args.kwargs["runtime_vars"] == {"MY_VAR": "my_value"} - def test_passes_effective_branch_to_remove( - self, - mock_workspace_resolve: MagicMock, - mock_remove_worktree: MagicMock, - ) -> None: + def test_passes_effective_branch(self, mock_service: MagicMock) -> None: remove(ctx=make_context(), effective_branch="gabriel/impersonated") - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_worktree.return_value - mock_remove_worktree.assert_called_once_with( - worktree, - runtime_vars={}, - force=False, - effective_branch="gabriel/impersonated", - ) + assert mock_service.remove.call_args.kwargs["effective_branch"] == "gabriel/impersonated" diff --git a/tests/unit/test_command_reset.py b/tests/unit/test_command_reset.py index 471855f..37baf37 100644 --- a/tests/unit/test_command_reset.py +++ b/tests/unit/test_command_reset.py @@ -13,12 +13,19 @@ @pytest.fixture(autouse=True) def mock_workspace_resolve(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.cli.commands.reset.Workspace.resolve") + mock = mocker.patch("git_workspace.cli.commands.reset.Workspace.resolve") + mock.return_value.resolve_worktree.return_value.branch = BRANCH + return mock @pytest.fixture(autouse=True) -def mock_reset_worktree(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.cli.commands.reset.operations.reset_worktree") +def mock_service_create(mocker: MockerFixture) -> MagicMock: + return mocker.patch("git_workspace.cli.commands.reset.WorkspaceService.create") + + +@pytest.fixture +def mock_service(mock_service_create: MagicMock) -> MagicMock: + return mock_service_create.return_value class TestReset: @@ -30,44 +37,26 @@ def test_resolves_worktree(self, mock_workspace_resolve: MagicMock) -> None: reset(ctx=make_context(), branch=BRANCH) mock_workspace_resolve.return_value.resolve_worktree.assert_called_once_with(BRANCH) - def test_resets_worktree_with_runtime_vars( - self, - mock_workspace_resolve: MagicMock, - mock_reset_worktree: MagicMock, + def test_delegates_to_forced_prepare( + self, mock_workspace_resolve: MagicMock, mock_service: MagicMock ) -> None: - reset(ctx=make_context(), runtime_vars=RUNTIME_VARS) # ty:ignore[invalid-argument-type] - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_worktree.return_value - mock_reset_worktree.assert_called_once_with( - worktree, - runtime_vars={"MY_VAR": "my_value"}, - effective_branch=None, - ) + worktree = mock_workspace_resolve.return_value.resolve_worktree.return_value - def test_resets_worktree_with_empty_runtime_vars_when_none( - self, - mock_workspace_resolve: MagicMock, - mock_reset_worktree: MagicMock, - ) -> None: - reset(ctx=make_context()) - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_worktree.return_value - mock_reset_worktree.assert_called_once_with( - worktree, + reset(ctx=make_context(), branch=BRANCH) + + mock_service.prepare_path.assert_called_once_with( + worktree.dir, + force=True, runtime_vars={}, effective_branch=None, ) - def test_passes_effective_branch_to_reset( - self, - mock_workspace_resolve: MagicMock, - mock_reset_worktree: MagicMock, - ) -> None: + def test_passes_runtime_vars(self, mock_service: MagicMock) -> None: + reset(ctx=make_context(), runtime_vars=RUNTIME_VARS) # ty:ignore[invalid-argument-type] + assert mock_service.prepare_path.call_args.kwargs["runtime_vars"] == {"MY_VAR": "my_value"} + + def test_passes_effective_branch(self, mock_service: MagicMock) -> None: reset(ctx=make_context(), effective_branch="gabriel/impersonated") - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_worktree.return_value - mock_reset_worktree.assert_called_once_with( - worktree, - runtime_vars={}, - effective_branch="gabriel/impersonated", + assert ( + mock_service.prepare_path.call_args.kwargs["effective_branch"] == "gabriel/impersonated" ) diff --git a/tests/unit/test_command_up.py b/tests/unit/test_command_up.py index f7b5a00..27c18c8 100644 --- a/tests/unit/test_command_up.py +++ b/tests/unit/test_command_up.py @@ -18,77 +18,98 @@ def mock_workspace_resolve(mocker: MockerFixture) -> MagicMock: @pytest.fixture(autouse=True) -def mock_activate_worktree(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.cli.commands.up.operations.activate_worktree") +def mock_service_create(mocker: MockerFixture) -> MagicMock: + return mocker.patch("git_workspace.cli.commands.up.WorkspaceService.create") + + +@pytest.fixture(autouse=True) +def mock_worktree_resolve(mocker: MockerFixture) -> MagicMock: + mock = mocker.patch("git_workspace.cli.commands.up.Worktree.resolve") + mock.return_value.branch = BRANCH + return mock + + +@pytest.fixture +def mock_service(mock_service_create: MagicMock) -> MagicMock: + return mock_service_create.return_value class TestUp: def test_resolves_workspace(self, mock_workspace_resolve: MagicMock) -> None: - up(ctx=make_context(WORKSPACE_DIR)) + up(ctx=make_context(WORKSPACE_DIR), branch=BRANCH) mock_workspace_resolve.assert_called_once_with(WORKSPACE_DIR) - def test_resolves_or_creates_worktree(self, mock_workspace_resolve: MagicMock) -> None: - up(ctx=make_context(), branch=BRANCH, base_branch=BASE_BRANCH) - mock_workspace_resolve.return_value.resolve_or_create_worktree.assert_called_once_with( - BRANCH, BASE_BRANCH - ) - - def test_activates_worktree_with_runtime_vars( - self, - mock_workspace_resolve: MagicMock, - mock_activate_worktree: MagicMock, + def test_builds_service_for_resolved_workspace( + self, mock_workspace_resolve: MagicMock, mock_service_create: MagicMock ) -> None: - up(ctx=make_context(), runtime_vars=RUNTIME_VARS) # ty:ignore[invalid-argument-type] - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_or_create_worktree.return_value - mock_activate_worktree.assert_called_once_with( - worktree, - runtime_vars={"MY_VAR": "my_value"}, + up(ctx=make_context(), branch=BRANCH) + assert mock_service_create.call_args.args == (mock_workspace_resolve.return_value,) + + def test_delegates_to_service_up(self, mock_service: MagicMock) -> None: + up(ctx=make_context(), branch=BRANCH, base_branch=BASE_BRANCH) + mock_service.up.assert_called_once_with( + BRANCH, + base_branch=BASE_BRANCH, + runtime_vars={}, detached=False, effective_branch=None, + focus=True, ) - def test_activates_worktree_with_empty_runtime_vars_when_none( + def test_resolves_branch_from_cwd_when_omitted( self, mock_workspace_resolve: MagicMock, - mock_activate_worktree: MagicMock, + mock_worktree_resolve: MagicMock, + mock_service: MagicMock, ) -> None: up(ctx=make_context()) - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_or_create_worktree.return_value - mock_activate_worktree.assert_called_once_with( - worktree, - runtime_vars={}, - detached=False, - effective_branch=None, - ) + mock_worktree_resolve.assert_called_once_with(mock_workspace_resolve.return_value, None) + assert mock_service.up.call_args.args[0] == BRANCH - def test_activates_worktree_as_detached( - self, - mock_workspace_resolve: MagicMock, - mock_activate_worktree: MagicMock, + def test_passes_runtime_vars(self, mock_service: MagicMock) -> None: + up(ctx=make_context(), branch=BRANCH, runtime_vars=RUNTIME_VARS) # ty:ignore[invalid-argument-type] + assert mock_service.up.call_args.kwargs["runtime_vars"] == {"MY_VAR": "my_value"} + + def test_passes_detached(self, mock_service: MagicMock) -> None: + up(ctx=make_context(), branch=BRANCH, detached=True) + assert mock_service.up.call_args.kwargs["detached"] is True + + def test_passes_effective_branch(self, mock_service: MagicMock) -> None: + up(ctx=make_context(), branch=BRANCH, effective_branch="gabriel/impersonated") + assert mock_service.up.call_args.kwargs["effective_branch"] == "gabriel/impersonated" + + +class TestBackendSelection: + def test_passes_backend_selection_to_service( + self, mock_workspace_resolve: MagicMock, mock_service_create: MagicMock ) -> None: - up(ctx=make_context(), detached=True) - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_or_create_worktree.return_value - mock_activate_worktree.assert_called_once_with( - worktree, - runtime_vars={}, - detached=True, - effective_branch=None, + from git_workspace.workspace.models import PresenterKind, ProviderKind + + up( + ctx=make_context(), + branch=BRANCH, + backend="herdr", + provider=ProviderKind.NATIVE_GIT, + presenter=PresenterKind.HERDR, ) - def test_passes_effective_branch_to_activate( - self, - mock_workspace_resolve: MagicMock, - mock_activate_worktree: MagicMock, - ) -> None: - up(ctx=make_context(), effective_branch="gabriel/impersonated") - workspace = mock_workspace_resolve.return_value - worktree = workspace.resolve_or_create_worktree.return_value - mock_activate_worktree.assert_called_once_with( - worktree, - runtime_vars={}, - detached=False, - effective_branch="gabriel/impersonated", + mock_service_create.assert_called_once_with( + mock_workspace_resolve.return_value, + backend_name="herdr", + provider_kind=ProviderKind.NATIVE_GIT, + presenter_kind=PresenterKind.HERDR, ) + + def test_defaults_to_no_explicit_selection(self, mock_service_create: MagicMock) -> None: + up(ctx=make_context(), branch=BRANCH) + + assert mock_service_create.call_args.kwargs == { + "backend_name": None, + "provider_kind": None, + "presenter_kind": None, + } + + def test_passes_focus_flag(self, mock_service: MagicMock) -> None: + up(ctx=make_context(), branch=BRANCH, focus=False) + + assert mock_service.up.call_args.kwargs["focus"] is False diff --git a/tests/unit/test_copier.py b/tests/unit/test_copier.py index 8564b51..d3873b8 100644 --- a/tests/unit/test_copier.py +++ b/tests/unit/test_copier.py @@ -6,9 +6,9 @@ from pyfakefs.fake_filesystem import FakeFilesystem from pytest_mock import MockerFixture -from git_workspace.assets import Copier, IgnoreManager from git_workspace.errors import WorkspaceCopyError from git_workspace.manifest import Copy +from git_workspace.workspace.assets import Copier, IgnoreManager ASSETS_DIR = Path("/workspace/.workspace/assets") WORKTREE_DIR = Path("/workspace/feat/GWS-001") @@ -55,7 +55,7 @@ def target(self) -> MagicMock: @pytest.fixture(autouse=True) def mock_git_skip_worktree(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.assets.git.skip_worktree") + return mocker.patch("git_workspace.workspace.assets.git.skip_worktree") @pytest.fixture(autouse=True) def mock_copy_with_substitution(self, mocker: MockerFixture, copier: Copier) -> MagicMock: @@ -194,7 +194,7 @@ def filesystem(self, fs: FakeFilesystem) -> None: @pytest.fixture(autouse=True) def mock_git_skip_worktree(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.assets.git.skip_worktree") + return mocker.patch("git_workspace.workspace.assets.git.skip_worktree") @pytest.fixture def copier(self, mocker: MockerFixture) -> Copier: @@ -225,7 +225,7 @@ def test_creates_deeply_nested_parent_dirs(self, copier: Copier) -> None: class TestSkipExisting: @pytest.fixture(autouse=True) def mock_git_skip_worktree(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.assets.git.skip_worktree") + return mocker.patch("git_workspace.workspace.assets.git.skip_worktree") @pytest.fixture def target_mock(self, copier: Copier) -> MagicMock: @@ -314,7 +314,7 @@ def mock_copy_with_substitution(self, mocker: MockerFixture, copier: Copier) -> @pytest.fixture(autouse=True) def mock_git_skip_worktree(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.assets.git.skip_worktree") + return mocker.patch("git_workspace.workspace.assets.git.skip_worktree") @pytest.fixture def target_mock(self, copier: Copier) -> MagicMock: @@ -508,7 +508,7 @@ def test_each_call_returns_its_own_count(self, copier: Copier, fs: FakeFilesyste def test_apply_returns_substitution_count( self, copier: Copier, fs: FakeFilesystem, mocker: MockerFixture ) -> None: - mocker.patch("git_workspace.assets.git.skip_worktree") + mocker.patch("git_workspace.workspace.assets.git.skip_worktree") fs.create_file( str(self.ASSETS_DIR / "template.txt.j2"), contents="{{ GIT_WORKSPACE_BRANCH }}" ) diff --git a/tests/unit/test_doctor_checks.py b/tests/unit/test_doctor_checks.py index 442bc56..ab61ebc 100644 --- a/tests/unit/test_doctor_checks.py +++ b/tests/unit/test_doctor_checks.py @@ -1329,3 +1329,73 @@ def test_attaches_auto_fix_that_removes_empty_groups( updated = tomllib.loads(manifest.read_text()) assert len(updated["hooks"]["on_setup"]) == 1 assert updated["hooks"]["on_setup"][0]["commands"] == ["real_cmd"] + + +class TestCheckWorkspaceState: + @pytest.fixture + def workspace_with_state(self, tmp_path): + from unittest.mock import MagicMock + + from git_workspace.workspace.models import ( + ManagedWorktree, + ProviderKind, + WorkspaceLifecycleState, + ) + from git_workspace.workspace.state import WorkspaceStateStore + + workspace = MagicMock() + workspace.dir = tmp_path + workspace.paths.state = tmp_path / ".workspace" / ".state" + store = WorkspaceStateStore(workspace.paths.state) + + worktree_dir = tmp_path / "feature" / "auth" + worktree_dir.mkdir(parents=True) + managed = ManagedWorktree( + repository_path=tmp_path, + worktree_path=worktree_dir, + branch="feature/auth", + provider_kind=ProviderKind.NATIVE_GIT, + ) + return workspace, store, managed, WorkspaceLifecycleState + + def test_no_findings_for_healthy_state(self, workspace_with_state): + from git_workspace.doctor import _check_workspace_state + + workspace, store, managed, states = workspace_with_state + store.save_created(managed) + store.set_state(managed.worktree_path, states.READY) + + findings = [] + _check_workspace_state(workspace, findings) + + assert findings == [] + + def test_flags_failed_preparation_with_retry_hint(self, workspace_with_state): + from git_workspace.doctor import _check_workspace_state + + workspace, store, managed, states = workspace_with_state + store.save_created(managed) + store.mark_preparation_failed(managed.worktree_path, error="hook exploded") + + findings = [] + _check_workspace_state(workspace, findings) + + assert len(findings) == 1 + assert "git workspace prepare --force" in findings[0].message + + def test_flags_stale_state_with_delete_fix(self, workspace_with_state): + import shutil + + from git_workspace.doctor import _check_workspace_state + + workspace, store, managed, states = workspace_with_state + store.save_created(managed) + shutil.rmtree(managed.worktree_path) + + findings = [] + _check_workspace_state(workspace, findings) + + assert len(findings) == 1 + assert findings[0].fix is not None + findings[0].fix.apply() + assert store.load(managed.worktree_path) is None diff --git a/tests/unit/test_env.py b/tests/unit/test_env.py index eb1a7c8..8ce895e 100644 --- a/tests/unit/test_env.py +++ b/tests/unit/test_env.py @@ -4,7 +4,7 @@ import pytest from pytest_mock import MockerFixture -from git_workspace.env import build_env +from git_workspace.workspace.env import build_env BRANCH = "feat/GWS-001" WORKSPACE_DIR = Path("/workspace") diff --git a/tests/unit/test_fingerprints.py b/tests/unit/test_fingerprints.py index c310111..2059310 100644 --- a/tests/unit/test_fingerprints.py +++ b/tests/unit/test_fingerprints.py @@ -5,12 +5,12 @@ import pytest from pytest_mock import MockerFixture -from git_workspace.fingerprint import ( +from git_workspace.manifest import Fingerprint +from git_workspace.workspace.fingerprint import ( DEFAULT_ALGORITHM, DEFAULT_LENGTH, compute_fingerprints, ) -from git_workspace.manifest import Fingerprint @pytest.fixture diff --git a/tests/unit/test_git.py b/tests/unit/test_git.py index a75cc56..3d72592 100644 --- a/tests/unit/test_git.py +++ b/tests/unit/test_git.py @@ -1,10 +1,8 @@ from pathlib import Path -from unittest.mock import MagicMock import pytest -from pytest_mock import MockerFixture -import git_workspace.git as git +import git_workspace.subprocesses.git as git from git_workspace.errors import ( GitCloneError, GitFetchError, @@ -13,6 +11,7 @@ WorktreeListingError, WorktreeRemovalError, ) +from tests.helpers import FakeCommandRunner URL = "https://github.com/user/repo.git" BRANCH = "feat/GWS-001" @@ -23,308 +22,346 @@ COMMIT_SHA = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" -@pytest.fixture(autouse=True) -def mock_subprocess_run(mocker: MockerFixture) -> MagicMock: - mock = mocker.patch("git_workspace.git.subprocess.run") - mock.return_value.returncode = 0 - mock.return_value.stdout = "" - mock.return_value.stderr = "" - return mock +@pytest.fixture +def runner() -> FakeCommandRunner: + return FakeCommandRunner() class TestClone: - def test_builds_basic_clone_command(self, mock_subprocess_run: MagicMock) -> None: - git.clone(URL) + def test_builds_basic_clone_command(self, runner: FakeCommandRunner) -> None: + git.clone(URL, runner=runner) - assert mock_subprocess_run.call_args.args[0] == ["git", "clone", URL] + assert runner.last_call.args == ("git", "clone", URL) - def test_appends_target_when_provided(self, mock_subprocess_run: MagicMock) -> None: - git.clone(URL, target=TARGET) + def test_appends_target_when_provided(self, runner: FakeCommandRunner) -> None: + git.clone(URL, target=TARGET, runner=runner) - assert mock_subprocess_run.call_args.args[0] == ["git", "clone", URL, TARGET] + assert runner.last_call.args == ("git", "clone", URL, str(TARGET)) - def test_appends_bare_flag_when_bare(self, mock_subprocess_run: MagicMock) -> None: - git.clone(URL, bare=True) + def test_appends_bare_flag_when_bare(self, runner: FakeCommandRunner) -> None: + git.clone(URL, bare=True, runner=runner) - assert mock_subprocess_run.call_args.args[0] == ["git", "clone", "--bare", URL] + assert runner.last_call.args == ("git", "clone", "--bare", URL) - def test_appends_branch_flags_when_provided(self, mock_subprocess_run: MagicMock) -> None: - git.clone(URL, branch=BASE_BRANCH) + def test_appends_branch_flags_when_provided(self, runner: FakeCommandRunner) -> None: + git.clone(URL, branch=BASE_BRANCH, runner=runner) - assert mock_subprocess_run.call_args.args[0] == [ + assert runner.last_call.args == ( "git", "clone", "-b", BASE_BRANCH, "--single-branch", URL, - ] + ) - def test_raises_git_clone_error_on_failure(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.returncode = 1 + def test_raises_git_clone_error_on_failure(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 1 with pytest.raises(GitCloneError): - git.clone(URL) + git.clone(URL, runner=runner) class TestInit: - def test_builds_init_command_with_target(self, mock_subprocess_run: MagicMock) -> None: - git.init(TARGET, bare=False) + def test_builds_init_command_with_target(self, runner: FakeCommandRunner) -> None: + git.init(TARGET, bare=False, runner=runner) - assert mock_subprocess_run.call_args.args[0] == ["git", "init", TARGET] + assert runner.last_call.args == ("git", "init", str(TARGET)) - def test_appends_bare_flag_when_bare(self, mock_subprocess_run: MagicMock) -> None: - git.init(TARGET, bare=True) + def test_appends_bare_flag_when_bare(self, runner: FakeCommandRunner) -> None: + git.init(TARGET, bare=True, runner=runner) - assert mock_subprocess_run.call_args.args[0] == ["git", "init", "--bare", TARGET] + assert runner.last_call.args == ("git", "init", "--bare", str(TARGET)) - def test_raises_git_init_error_on_failure(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.returncode = 1 + def test_raises_git_init_error_on_failure(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 1 with pytest.raises(GitInitError): - git.init(TARGET, bare=False) + git.init(TARGET, bare=False, runner=runner) class TestListWorktrees: - def test_builds_list_command_with_cwd(self, mock_subprocess_run: MagicMock) -> None: - git.list_worktrees(CWD) + def test_builds_list_command_with_cwd(self, runner: FakeCommandRunner) -> None: + git.list_worktrees(CWD, runner=runner) - assert mock_subprocess_run.call_args.args[0] == ["git", "worktree", "list", "--porcelain"] - assert mock_subprocess_run.call_args.kwargs["cwd"] == CWD + assert runner.last_call.args == ("git", "worktree", "list", "--porcelain") + assert runner.last_call.cwd == CWD - def test_returns_parsed_worktrees(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.stdout = ( - f"worktree {WORKTREE_DIR}\nHEAD {COMMIT_SHA}\nbranch refs/heads/{BRANCH}" + def test_returns_parsed_worktrees(self, runner: FakeCommandRunner) -> None: + runner.queue( + stdout=f"worktree {WORKTREE_DIR}\nHEAD {COMMIT_SHA}\nbranch refs/heads/{BRANCH}" ) - result = git.list_worktrees(CWD) + result = git.list_worktrees(CWD, runner=runner) assert result == [{"directory": str(WORKTREE_DIR), "head": COMMIT_SHA, "branch": BRANCH}] - def test_returns_empty_list_when_no_matches(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.stdout = "" + def test_skips_detached_head_worktrees(self, runner: FakeCommandRunner) -> None: + runner.queue( + stdout=( + f"worktree {WORKTREE_DIR}\nHEAD {COMMIT_SHA}\nbranch refs/heads/{BRANCH}\n" + "\n" + f"worktree /workspace/detached\nHEAD {COMMIT_SHA}\ndetached" + ) + ) + + result = git.list_worktrees(CWD, runner=runner) + + assert result == [{"directory": str(WORKTREE_DIR), "head": COMMIT_SHA, "branch": BRANCH}] - result = git.list_worktrees(CWD) + def test_returns_empty_list_when_no_matches(self, runner: FakeCommandRunner) -> None: + result = git.list_worktrees(CWD, runner=runner) assert result == [] - def test_raises_worktree_listing_error_on_failure(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.returncode = 1 + def test_raises_worktree_listing_error_on_failure(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 1 with pytest.raises(WorktreeListingError): - git.list_worktrees(CWD) + git.list_worktrees(CWD, runner=runner) class TestConfigureRemoteFetchRefspec: - def test_sets_correct_refspec(self, mock_subprocess_run: MagicMock) -> None: - git.configure_remote_fetch_refspec(CWD) + def test_sets_correct_refspec(self, runner: FakeCommandRunner) -> None: + git.configure_remote_fetch_refspec(CWD, runner=runner) - assert mock_subprocess_run.call_args.args[0] == [ + assert runner.last_call.args == ( "git", "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*", - ] - assert mock_subprocess_run.call_args.kwargs["cwd"] == CWD + ) + assert runner.last_call.cwd == CWD class TestFetchOrigin: - def test_builds_fetch_command_with_cwd(self, mock_subprocess_run: MagicMock) -> None: - git.fetch_origin(CWD) + def test_builds_fetch_command_with_cwd(self, runner: FakeCommandRunner) -> None: + git.fetch_origin(CWD, runner=runner) - assert mock_subprocess_run.call_args.args[0] == ["git", "fetch", "origin", "--prune"] - assert mock_subprocess_run.call_args.kwargs["cwd"] == CWD + assert runner.last_call.args == ("git", "fetch", "origin", "--prune") + assert runner.last_call.cwd == CWD - def test_raises_git_fetch_error_on_failure(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.returncode = 1 + def test_raises_git_fetch_error_on_failure(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 1 with pytest.raises(GitFetchError): - git.fetch_origin(CWD) + git.fetch_origin(CWD, runner=runner) class TestLocalBranchExists: - def test_builds_correct_command(self, mock_subprocess_run: MagicMock) -> None: - git.local_branch_exists(BRANCH, CWD) + def test_builds_correct_command(self, runner: FakeCommandRunner) -> None: + git.local_branch_exists(BRANCH, CWD, runner=runner) - assert mock_subprocess_run.call_args.args[0] == [ + assert runner.last_call.args == ( "git", "rev-parse", "--verify", "--quiet", f"refs/heads/{BRANCH}", - ] - - def test_returns_true_when_branch_exists(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.returncode = 0 + ) - assert git.local_branch_exists(BRANCH, CWD) is True + def test_returns_true_when_branch_exists(self, runner: FakeCommandRunner) -> None: + assert git.local_branch_exists(BRANCH, CWD, runner=runner) is True - def test_returns_false_when_branch_not_found(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.returncode = 1 + def test_returns_false_when_branch_not_found(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 1 - assert git.local_branch_exists(BRANCH, CWD) is False + assert git.local_branch_exists(BRANCH, CWD, runner=runner) is False class TestRemoteBranchExists: - def test_builds_correct_command(self, mock_subprocess_run: MagicMock) -> None: - git.remote_branch_exists(BRANCH, CWD) + def test_builds_correct_command(self, runner: FakeCommandRunner) -> None: + git.remote_branch_exists(BRANCH, CWD, runner=runner) - assert mock_subprocess_run.call_args.args[0] == [ + assert runner.last_call.args == ( "git", "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{BRANCH}", - ] - - def test_returns_true_when_branch_exists(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.returncode = 0 + ) - assert git.remote_branch_exists(BRANCH, CWD) is True + def test_returns_true_when_branch_exists(self, runner: FakeCommandRunner) -> None: + assert git.remote_branch_exists(BRANCH, CWD, runner=runner) is True - def test_returns_false_when_branch_not_found(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.returncode = 1 + def test_returns_false_when_branch_not_found(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 1 - assert git.remote_branch_exists(BRANCH, CWD) is False + assert git.remote_branch_exists(BRANCH, CWD, runner=runner) is False class TestSkipWorktree: - def test_builds_correct_command(self, mock_subprocess_run: MagicMock) -> None: - git.skip_worktree(TARGET) + def test_builds_correct_command(self, runner: FakeCommandRunner) -> None: + git.skip_worktree(TARGET, runner=runner) - assert mock_subprocess_run.call_args.args[0] == [ + assert runner.last_call.args == ( "git", "update-index", "--skip-worktree", - TARGET, - ] + str(TARGET), + ) - def test_does_not_raise_on_failure(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.returncode = 1 + def test_does_not_raise_on_failure(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 1 - git.skip_worktree(TARGET) + git.skip_worktree(TARGET, runner=runner) class TestCreateWorktreeFromLocalBranch: - def test_builds_correct_command(self, mock_subprocess_run: MagicMock) -> None: - git.create_worktree_from_local_branch(WORKTREE_DIR, BRANCH, CWD) + def test_builds_correct_command(self, runner: FakeCommandRunner) -> None: + git.create_worktree_from_local_branch(WORKTREE_DIR, BRANCH, CWD, runner=runner) - assert mock_subprocess_run.call_args.args[0] == [ + assert runner.last_call.args == ( "git", "worktree", "add", - WORKTREE_DIR, + str(WORKTREE_DIR), BRANCH, - ] - assert mock_subprocess_run.call_args.kwargs["cwd"] == CWD + ) + assert runner.last_call.cwd == CWD - def test_raises_worktree_creation_error_on_failure( - self, mock_subprocess_run: MagicMock - ) -> None: - mock_subprocess_run.return_value.returncode = 1 + def test_raises_worktree_creation_error_on_failure(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 1 with pytest.raises(WorktreeCreationError): - git.create_worktree_from_local_branch(WORKTREE_DIR, BRANCH, CWD) + git.create_worktree_from_local_branch(WORKTREE_DIR, BRANCH, CWD, runner=runner) class TestCreateWorktreeFromRemoteBranch: - def test_builds_correct_command(self, mock_subprocess_run: MagicMock) -> None: - git.create_worktree_from_remote_branch(WORKTREE_DIR, BRANCH, CWD) + def test_builds_correct_command(self, runner: FakeCommandRunner) -> None: + git.create_worktree_from_remote_branch(WORKTREE_DIR, BRANCH, CWD, runner=runner) - assert mock_subprocess_run.call_args.args[0] == [ + assert runner.last_call.args == ( "git", "worktree", "add", "--track", "-b", BRANCH, - WORKTREE_DIR, + str(WORKTREE_DIR), f"origin/{BRANCH}", - ] - assert mock_subprocess_run.call_args.kwargs["cwd"] == CWD + ) + assert runner.last_call.cwd == CWD - def test_raises_worktree_creation_error_on_failure( - self, mock_subprocess_run: MagicMock - ) -> None: - mock_subprocess_run.return_value.returncode = 1 + def test_raises_worktree_creation_error_on_failure(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 1 with pytest.raises(WorktreeCreationError): - git.create_worktree_from_remote_branch(WORKTREE_DIR, BRANCH, CWD) + git.create_worktree_from_remote_branch(WORKTREE_DIR, BRANCH, CWD, runner=runner) class TestCreateWorktreeNew: - def test_builds_correct_command(self, mock_subprocess_run: MagicMock) -> None: - git.create_worktree_new(WORKTREE_DIR, BRANCH, BASE_BRANCH, CWD) + def test_builds_correct_command(self, runner: FakeCommandRunner) -> None: + git.create_worktree_new(WORKTREE_DIR, BRANCH, BASE_BRANCH, CWD, runner=runner) - assert mock_subprocess_run.call_args.args[0] == [ + assert runner.last_call.args == ( "git", "worktree", "add", "-b", BRANCH, - WORKTREE_DIR, + str(WORKTREE_DIR), BASE_BRANCH, - ] - assert mock_subprocess_run.call_args.kwargs["cwd"] == CWD + ) + assert runner.last_call.cwd == CWD - def test_raises_worktree_creation_error_on_failure( - self, mock_subprocess_run: MagicMock + def test_falls_back_to_orphan_worktree_when_base_missing( + self, runner: FakeCommandRunner ) -> None: - mock_subprocess_run.return_value.returncode = 1 + runner.queue(exit_code=1) + runner.queue(exit_code=0) + + git.create_worktree_new(WORKTREE_DIR, BRANCH, BASE_BRANCH, CWD, runner=runner) + + assert runner.last_call.args == ( + "git", + "worktree", + "add", + "--orphan", + "-b", + BRANCH, + str(WORKTREE_DIR), + ) + + def test_raises_worktree_creation_error_on_failure(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 1 with pytest.raises(WorktreeCreationError): - git.create_worktree_new(WORKTREE_DIR, BRANCH, BASE_BRANCH, CWD) + git.create_worktree_new(WORKTREE_DIR, BRANCH, BASE_BRANCH, CWD, runner=runner) + + +class TestGitCommonDir: + def test_resolves_relative_output_against_path(self, runner: FakeCommandRunner) -> None: + runner.queue(stdout=".git\n") + + result = git.git_common_dir(CWD, runner=runner) + + assert runner.last_call.args == ("git", "rev-parse", "--git-common-dir") + assert runner.last_call.cwd == CWD + assert result == (CWD / ".git").resolve() + + def test_returns_absolute_output_as_is(self, runner: FakeCommandRunner) -> None: + runner.queue(stdout="/workspace/.git\n") + + result = git.git_common_dir(WORKTREE_DIR, runner=runner) + + assert result == Path("/workspace/.git").resolve() + + def test_returns_none_outside_a_repository(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 128 + + assert git.git_common_dir(CWD, runner=runner) is None class TestTryGetWorktreeDir: - def test_returns_stripped_stdout_when_in_worktree(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.stdout = f"{WORKTREE_DIR}\n" + def test_returns_stripped_stdout_when_in_worktree(self, runner: FakeCommandRunner) -> None: + runner.queue(stdout=f"{WORKTREE_DIR}\n") - result = git.try_get_worktree_dir() + result = git.try_get_worktree_dir(runner=runner) assert result == str(WORKTREE_DIR) - def test_returns_none_when_not_in_worktree(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.returncode = 1 + def test_returns_none_when_not_in_worktree(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 1 - result = git.try_get_worktree_dir() + result = git.try_get_worktree_dir(runner=runner) assert result is None class TestGetWorktreeBranch: - def test_returns_stripped_stdout(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.stdout = f"{BRANCH}\n" + def test_returns_stripped_stdout(self, runner: FakeCommandRunner) -> None: + runner.queue(stdout=f"{BRANCH}\n") - result = git.get_worktree_branch(str(CWD)) + result = git.get_worktree_branch(str(CWD), runner=runner) assert result == BRANCH class TestRemoveWorktree: - def test_builds_basic_remove_command(self, mock_subprocess_run: MagicMock) -> None: - git.remove_worktree(WORKTREE_DIR, cwd=CWD) + def test_builds_basic_remove_command(self, runner: FakeCommandRunner) -> None: + git.remove_worktree(WORKTREE_DIR, cwd=CWD, runner=runner) - assert mock_subprocess_run.call_args.args[0] == ["git", "worktree", "remove", WORKTREE_DIR] + assert runner.last_call.args == ("git", "worktree", "remove", str(WORKTREE_DIR)) - def test_passes_cwd(self, mock_subprocess_run: MagicMock) -> None: - git.remove_worktree(WORKTREE_DIR, cwd=CWD) + def test_passes_cwd(self, runner: FakeCommandRunner) -> None: + git.remove_worktree(WORKTREE_DIR, cwd=CWD, runner=runner) - assert mock_subprocess_run.call_args.kwargs["cwd"] == CWD + assert runner.last_call.cwd == CWD - def test_appends_force_flag_when_force(self, mock_subprocess_run: MagicMock) -> None: - git.remove_worktree(WORKTREE_DIR, force=True, cwd=CWD) + def test_appends_force_flag_when_force(self, runner: FakeCommandRunner) -> None: + git.remove_worktree(WORKTREE_DIR, force=True, cwd=CWD, runner=runner) - assert mock_subprocess_run.call_args.args[0] == [ + assert runner.last_call.args == ( "git", "worktree", "remove", "--force", - WORKTREE_DIR, - ] + str(WORKTREE_DIR), + ) - def test_raises_worktree_removal_error_on_failure(self, mock_subprocess_run: MagicMock) -> None: - mock_subprocess_run.return_value.returncode = 1 + def test_raises_worktree_removal_error_on_failure(self, runner: FakeCommandRunner) -> None: + runner.default_exit_code = 1 with pytest.raises(WorktreeRemovalError): - git.remove_worktree(WORKTREE_DIR, cwd=CWD) + git.remove_worktree(WORKTREE_DIR, cwd=CWD, runner=runner) diff --git a/tests/unit/test_herdr.py b/tests/unit/test_herdr.py new file mode 100644 index 0000000..d229727 --- /dev/null +++ b/tests/unit/test_herdr.py @@ -0,0 +1,179 @@ +import json +from pathlib import Path + +import pytest + +import git_workspace.subprocesses.herdr as herdr +from git_workspace.errors import HerdrError +from tests.helpers import FakeCommandRunner + +REPO = Path("/workspace") +TARGET = Path("/workspace/feature/x") +HERDR = "herdr" + + +@pytest.fixture +def runner() -> FakeCommandRunner: + return FakeCommandRunner() + + +def ok(result: dict) -> str: + return json.dumps({"id": "cli:test", "result": result}) + + +class TestResolveExecutable: + def test_explicit_value_wins(self, monkeypatch) -> None: + monkeypatch.setenv("HERDR_BIN_PATH", "/env/herdr") + + assert herdr.resolve_executable("/explicit/herdr") == "/explicit/herdr" + + def test_env_var_beats_path(self, monkeypatch, mocker) -> None: + monkeypatch.setenv("HERDR_BIN_PATH", "/env/herdr") + mocker.patch("git_workspace.subprocesses.herdr.shutil.which", return_value="/path/herdr") + + assert herdr.resolve_executable() == "/env/herdr" + + def test_falls_back_to_path(self, mocker) -> None: + mocker.patch("git_workspace.subprocesses.herdr.shutil.which", return_value="/path/herdr") + + assert herdr.resolve_executable() == "/path/herdr" + + def test_none_when_unavailable(self, mocker) -> None: + mocker.patch("git_workspace.subprocesses.herdr.shutil.which", return_value=None) + + assert herdr.resolve_executable() is None + + +class TestInVerifiedContext: + def test_true_with_env_marker_and_existing_socket(self, monkeypatch, tmp_path) -> None: + socket = tmp_path / "herdr.sock" + socket.touch() + monkeypatch.setenv("HERDR_ENV", "1") + monkeypatch.setenv("HERDR_SOCKET_PATH", str(socket)) + + assert herdr.in_verified_context() is True + + def test_false_without_env_marker(self, monkeypatch, tmp_path) -> None: + socket = tmp_path / "herdr.sock" + socket.touch() + monkeypatch.setenv("HERDR_SOCKET_PATH", str(socket)) + + assert herdr.in_verified_context() is False + + def test_false_when_socket_missing(self, monkeypatch, tmp_path) -> None: + monkeypatch.setenv("HERDR_ENV", "1") + monkeypatch.setenv("HERDR_SOCKET_PATH", str(tmp_path / "gone.sock")) + + assert herdr.in_verified_context() is False + + +class TestCommands: + def test_list_worktrees_argv(self, runner: FakeCommandRunner) -> None: + runner.queue(stdout=ok({"worktrees": []})) + + herdr.list_worktrees(REPO, executable=HERDR, runner=runner) + + assert runner.last_call.args == ("herdr", "worktree", "list", "--cwd", str(REPO), "--json") + + def test_create_worktree_argv_with_base(self, runner: FakeCommandRunner) -> None: + runner.queue(stdout=ok({"type": "worktree_created"})) + + herdr.create_worktree(REPO, "feature/x", TARGET, "main", executable=HERDR, runner=runner) + + assert runner.last_call.args == ( + "herdr", + "worktree", + "create", + "--cwd", + str(REPO), + "--branch", + "feature/x", + "--path", + str(TARGET), + "--base", + "main", + "--no-focus", + "--json", + ) + + def test_create_worktree_omits_base_when_none(self, runner: FakeCommandRunner) -> None: + runner.queue(stdout=ok({})) + + herdr.create_worktree(REPO, "feature/x", TARGET, None, executable=HERDR, runner=runner) + + assert "--base" not in runner.last_call.args + + def test_open_worktree_argv(self, runner: FakeCommandRunner) -> None: + runner.queue(stdout=ok({"type": "worktree_opened"})) + + herdr.open_worktree(REPO, TARGET, executable=HERDR, runner=runner) + + assert runner.last_call.args == ( + "herdr", + "worktree", + "open", + "--cwd", + str(REPO), + "--path", + str(TARGET), + "--no-focus", + "--json", + ) + + def test_remove_worktree_argv_with_force(self, runner: FakeCommandRunner) -> None: + runner.queue(stdout=ok({"type": "worktree_removed"})) + + herdr.remove_worktree("w8", force=True, executable=HERDR, runner=runner) + + assert runner.last_call.args == ( + "herdr", + "worktree", + "remove", + "--workspace", + "w8", + "--force", + "--json", + ) + + def test_focus_and_close_argv(self, runner: FakeCommandRunner) -> None: + runner.queue(stdout=ok({"type": "ok"})) + herdr.focus_workspace("w8", executable=HERDR, runner=runner) + assert runner.last_call.args == ("herdr", "workspace", "focus", "w8") + + runner.queue(stdout=ok({"type": "ok"})) + herdr.close_workspace("w8", executable=HERDR, runner=runner) + assert runner.last_call.args == ("herdr", "workspace", "close", "w8") + + +class TestEnvelopeParsing: + def test_returns_result_payload(self, runner: FakeCommandRunner) -> None: + runner.queue(stdout=ok({"type": "worktree_list", "worktrees": [{"path": "/x"}]})) + + result = herdr.list_worktrees(REPO, executable=HERDR, runner=runner) + + assert result["worktrees"] == [{"path": "/x"}] + + def test_error_envelope_raises_even_with_exit_zero(self, runner: FakeCommandRunner) -> None: + runner.queue( + exit_code=0, + stdout=json.dumps( + {"error": {"code": "not_git_worktree", "message": "not a work tree"}} + ), + ) + + with pytest.raises(HerdrError) as excinfo: + herdr.list_worktrees(REPO, executable=HERDR, runner=runner) + + assert excinfo.value.code == "not_git_worktree" + + def test_malformed_output_raises(self, runner: FakeCommandRunner) -> None: + runner.queue(stdout="not json at all") + + with pytest.raises(HerdrError, match="malformed"): + herdr.list_worktrees(REPO, executable=HERDR, runner=runner) + + def test_nonzero_exit_without_error_payload_raises(self, runner: FakeCommandRunner) -> None: + runner.queue(exit_code=1, stdout="{}", stderr="server not running") + + with pytest.raises(HerdrError, match="server not running"): + herdr.list_worktrees(REPO, executable=HERDR, runner=runner) diff --git a/tests/unit/test_herdr_presenter.py b/tests/unit/test_herdr_presenter.py new file mode 100644 index 0000000..1fedb5f --- /dev/null +++ b/tests/unit/test_herdr_presenter.py @@ -0,0 +1,251 @@ +import json +from pathlib import Path + +import pytest + +from git_workspace.errors import ( + PresentationNotFoundError, + PresenterError, + PresenterUnavailableError, +) +from git_workspace.presenters.base import WorkspacePresenter +from git_workspace.presenters.herdr import HerdrPresenter +from git_workspace.workspace.models import ( + ManagedWorktree, + Presentation, + PresenterKind, + ProviderKind, +) +from tests.helpers import FakeCommandRunner + +HERDR = "/fake/herdr" +BRANCH = "feature/x" + + +@pytest.fixture +def runner() -> FakeCommandRunner: + return FakeCommandRunner() + + +@pytest.fixture +def presenter(runner: FakeCommandRunner) -> HerdrPresenter: + return HerdrPresenter(runner, executable=HERDR) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + d = tmp_path / "repo" + d.mkdir() + return d + + +@pytest.fixture +def worktree(repo: Path) -> ManagedWorktree: + wt = repo / BRANCH + wt.mkdir(parents=True) + return ManagedWorktree( + repository_path=repo, + worktree_path=wt, + branch=BRANCH, + provider_kind=ProviderKind.HERDR, + ) + + +def opened_payload(workspace_id: str, *, already_open: bool = False) -> str: + return json.dumps( + { + "id": "cli:worktree:open", + "result": { + "type": "worktree_opened", + "already_open": already_open, + "workspace": {"workspace_id": workspace_id}, + }, + } + ) + + +def list_payload(repo: Path, path: Path, workspace_id: str | None) -> str: + entry = { + "branch": BRANCH, + "is_linked_worktree": True, + "path": str(path), + } + if workspace_id: + entry["open_workspace_id"] = workspace_id + return json.dumps({"result": {"source": {"repo_root": str(repo)}, "worktrees": [entry]}}) + + +def error_payload(code: str) -> str: + return json.dumps({"error": {"code": code, "message": code}}) + + +def ok_payload() -> str: + return json.dumps({"result": {"type": "ok"}}) + + +class TestProtocol: + def test_satisfies_presenter_protocol(self, presenter: HerdrPresenter) -> None: + assert isinstance(presenter, WorkspacePresenter) + + def test_kind_is_herdr(self, presenter: HerdrPresenter) -> None: + assert presenter.kind is PresenterKind.HERDR + + def test_capabilities_are_fully_supported(self, presenter: HerdrPresenter) -> None: + caps = presenter.capabilities + + assert caps.can_find_existing + assert caps.can_focus_existing + assert caps.can_close + assert caps.can_list + assert caps.supports_runtime_identity + + def test_unavailable_without_executable( + self, runner: FakeCommandRunner, mocker, worktree: ManagedWorktree + ) -> None: + mocker.patch("git_workspace.subprocesses.herdr.shutil.which", return_value=None) + bare = HerdrPresenter(runner) + + assert bare.is_available() is False + with pytest.raises(PresenterUnavailableError): + bare.open(worktree) + + +class TestOpen: + def test_opens_without_focus_and_returns_presentation( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, worktree: ManagedWorktree + ) -> None: + runner.queue(stdout=opened_payload("w9")) + + presentation = presenter.open(worktree) + + assert "--no-focus" in runner.last_call.args + assert presentation.presenter_kind is PresenterKind.HERDR + assert presentation.presentation_id == "w9" + + def test_open_is_idempotent( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, worktree: ManagedWorktree + ) -> None: + runner.queue(stdout=opened_payload("w9")) + runner.queue(stdout=opened_payload("w9", already_open=True)) + + first = presenter.open(worktree) + second = presenter.open(worktree) + + assert first.presentation_id == second.presentation_id + + def test_raises_when_no_workspace_id_returned( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, worktree: ManagedWorktree + ) -> None: + runner.queue(stdout=json.dumps({"result": {"type": "worktree_opened"}})) + + with pytest.raises(PresenterError): + presenter.open(worktree) + + +class TestFind: + def test_finds_presentation_by_canonical_path( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, worktree: ManagedWorktree + ) -> None: + runner.queue(stdout=list_payload(worktree.repository_path, worktree.worktree_path, "w9")) + + result = presenter.find(worktree.worktree_path / "x" / "..") + + assert result is not None + assert result.presentation_id == "w9" + + def test_returns_none_when_not_presented( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, worktree: ManagedWorktree + ) -> None: + runner.queue(stdout=list_payload(worktree.repository_path, worktree.worktree_path, None)) + + assert presenter.find(worktree.worktree_path) is None + + def test_returns_none_outside_git( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, tmp_path: Path + ) -> None: + runner.queue(stdout=error_payload("not_git_worktree")) + + assert presenter.find(tmp_path) is None + + +class TestFocus: + def test_focuses_given_presentation( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, worktree: ManagedWorktree + ) -> None: + runner.queue(stdout=ok_payload()) + presentation = Presentation(presenter_kind=PresenterKind.HERDR, presentation_id="w9") + + result = presenter.focus(worktree, presentation) + + assert runner.last_call.args == (HERDR, "workspace", "focus", "w9") + assert result is presentation + + def test_opens_when_no_presentation_exists( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, worktree: ManagedWorktree + ) -> None: + runner.queue(stdout=list_payload(worktree.repository_path, worktree.worktree_path, None)) + runner.queue(stdout=opened_payload("w10")) + runner.queue(stdout=ok_payload()) + + result = presenter.focus(worktree) + + assert result.presentation_id == "w10" + assert runner.last_call.args == (HERDR, "workspace", "focus", "w10") + + def test_raises_presentation_not_found_for_stale_id( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, worktree: ManagedWorktree + ) -> None: + runner.queue(stdout=error_payload("workspace_not_found")) + presentation = Presentation(presenter_kind=PresenterKind.HERDR, presentation_id="w404") + + with pytest.raises(PresentationNotFoundError): + presenter.focus(worktree, presentation) + + +class TestClose: + def test_closes_given_presentation( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, worktree: ManagedWorktree + ) -> None: + runner.queue(stdout=ok_payload()) + presentation = Presentation(presenter_kind=PresenterKind.HERDR, presentation_id="w9") + + presenter.close(worktree, presentation) + + assert runner.last_call.args == (HERDR, "workspace", "close", "w9") + + def test_is_a_noop_when_nothing_presented( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, worktree: ManagedWorktree + ) -> None: + runner.queue(stdout=list_payload(worktree.repository_path, worktree.worktree_path, None)) + + presenter.close(worktree) + + assert len(runner.calls) == 1 # only the find lookup + + def test_tolerates_already_closed_workspace( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, worktree: ManagedWorktree + ) -> None: + runner.queue(stdout=error_payload("workspace_not_found")) + presentation = Presentation(presenter_kind=PresenterKind.HERDR, presentation_id="w9") + + presenter.close(worktree, presentation) + + +class TestList: + def test_lists_presented_worktrees( + self, presenter: HerdrPresenter, runner: FakeCommandRunner, worktree: ManagedWorktree + ) -> None: + runner.queue(stdout=list_payload(worktree.repository_path, worktree.worktree_path, "w9")) + + result = presenter.list(worktree.repository_path) + + assert result == [ + ( + worktree.worktree_path, + Presentation(presenter_kind=PresenterKind.HERDR, presentation_id="w9"), + ) + ] + + def test_requires_repository_path(self, presenter: HerdrPresenter) -> None: + with pytest.raises(PresenterError): + presenter.list(None) diff --git a/tests/unit/test_herdr_provider.py b/tests/unit/test_herdr_provider.py new file mode 100644 index 0000000..302d6c3 --- /dev/null +++ b/tests/unit/test_herdr_provider.py @@ -0,0 +1,319 @@ +import json +from pathlib import Path + +import pytest + +from git_workspace.errors import ( + ProviderError, + ProviderUnavailableError, + WorktreeAlreadyExistsError, + WorktreeCreationError, + WorktreeNotFoundError, + WorktreeRemovalError, +) +from git_workspace.providers.base import WorktreeProvider +from git_workspace.providers.herdr import HerdrWorktreeProvider +from git_workspace.workspace.models import ProviderKind, WorktreeRequest +from tests.helpers import FakeCommandRunner + +HERDR = "/fake/herdr" +BRANCH = "feature/x" +BASE_BRANCH = "main" + + +@pytest.fixture +def runner() -> FakeCommandRunner: + return FakeCommandRunner() + + +@pytest.fixture +def provider(runner: FakeCommandRunner) -> HerdrWorktreeProvider: + return HerdrWorktreeProvider(runner, executable=HERDR) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + d = tmp_path / "repo" + d.mkdir() + return d + + +def entry(path: Path, branch: str = BRANCH, workspace_id: str | None = None) -> dict: + data = { + "branch": branch, + "is_bare": False, + "is_detached": False, + "is_linked_worktree": True, + "is_prunable": False, + "label": "repo", + "path": str(path), + } + if workspace_id: + data["open_workspace_id"] = workspace_id + return data + + +def list_payload(repo: Path, *entries: dict) -> str: + return json.dumps( + { + "id": "cli:worktree:list", + "result": { + "source": {"repo_root": str(repo), "repo_key": str(repo / ".git")}, + "type": "worktree_list", + "worktrees": [ + {"is_bare": True, "is_linked_worktree": False, "path": str(repo)}, + *entries, + ], + }, + } + ) + + +def created_payload(path: Path, branch: str, workspace_id: str) -> str: + return json.dumps( + { + "id": "cli:worktree:create", + "result": { + "type": "worktree_created", + "workspace": {"workspace_id": workspace_id, "label": path.name}, + "worktree": entry(path, branch, workspace_id), + }, + } + ) + + +def error_payload(code: str, message: str = "boom") -> str: + return json.dumps({"error": {"code": code, "message": message}, "id": "cli:x"}) + + +class TestProtocol: + def test_satisfies_worktree_provider_protocol(self, provider: HerdrWorktreeProvider) -> None: + assert isinstance(provider, WorktreeProvider) + + def test_kind_is_herdr(self, provider: HerdrWorktreeProvider) -> None: + assert provider.kind is ProviderKind.HERDR + + def test_is_available_with_executable(self, provider: HerdrWorktreeProvider) -> None: + assert provider.is_available() is True + + def test_is_unavailable_without_executable( + self, runner: FakeCommandRunner, mocker, monkeypatch + ) -> None: + mocker.patch("git_workspace.subprocesses.herdr.shutil.which", return_value=None) + + assert HerdrWorktreeProvider(runner).is_available() is False + + def test_operations_raise_when_unavailable( + self, runner: FakeCommandRunner, mocker, repo: Path + ) -> None: + mocker.patch("git_workspace.subprocesses.herdr.shutil.which", return_value=None) + + with pytest.raises(ProviderUnavailableError): + HerdrWorktreeProvider(runner).list(repo) + + +class TestCreate: + def test_creates_and_records_workspace_id( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + target = repo / BRANCH + runner.queue(stdout=list_payload(repo)) + runner.queue(stdout=created_payload(target, BRANCH, "w8")) + + result = provider.create( + WorktreeRequest( + repository_path=repo, branch=BRANCH, base_branch=BASE_BRANCH, target_path=target + ) + ) + + assert runner.last_call.args == ( + HERDR, + "worktree", + "create", + "--cwd", + str(repo.resolve()), + "--branch", + BRANCH, + "--path", + str(target.resolve()), + "--base", + BASE_BRANCH, + "--no-focus", + "--json", + ) + assert result.provider_kind is ProviderKind.HERDR + assert result.provider_id == "w8" + assert result.metadata == {"workspace_id": "w8"} + assert result.branch == BRANCH + + def test_never_focuses( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + target = repo / BRANCH + runner.queue(stdout=list_payload(repo)) + runner.queue(stdout=created_payload(target, BRANCH, "w8")) + + provider.create(WorktreeRequest(repository_path=repo, branch=BRANCH, target_path=target)) + + assert "--no-focus" in runner.last_call.args + assert not any("focus" == arg for call in runner.calls for arg in call.args) + + def test_raises_when_target_already_registered( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + target = repo / BRANCH + runner.queue(stdout=list_payload(repo, entry(target))) + + with pytest.raises(WorktreeAlreadyExistsError): + provider.create( + WorktreeRequest(repository_path=repo, branch="other", target_path=target) + ) + + def test_wraps_herdr_failure( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + runner.queue(stdout=list_payload(repo)) + runner.queue(stdout=error_payload("worktree_create_failed")) + + with pytest.raises(WorktreeCreationError): + provider.create( + WorktreeRequest(repository_path=repo, branch=BRANCH, target_path=repo / BRANCH) + ) + + +class TestList: + def test_skips_bare_and_unbranched_entries( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + wt = repo / BRANCH + runner.queue(stdout=list_payload(repo, entry(wt, workspace_id="w3"))) + + result = provider.list(repo) + + assert len(result) == 1 + assert result[0].worktree_path == wt.resolve() + assert result[0].metadata == {"workspace_id": "w3"} + + def test_requires_repository_path(self, provider: HerdrWorktreeProvider) -> None: + with pytest.raises(WorktreeNotFoundError): + provider.list(None) + + def test_wraps_herdr_failure( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + runner.queue(stdout="garbage output") + + with pytest.raises(ProviderError): + provider.list(repo) + + +class TestFindAndImport: + def test_finds_by_canonical_path( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + wt = repo / BRANCH + wt.mkdir(parents=True) + runner.queue(stdout=list_payload(repo, entry(wt))) + + result = provider.find(wt / "sub" / "..") + + assert result is not None + assert result.worktree_path == wt.resolve() + + def test_find_returns_none_for_missing_directory( + self, provider: HerdrWorktreeProvider, tmp_path: Path + ) -> None: + assert provider.find(tmp_path / "missing") is None + + def test_find_returns_none_outside_git( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, tmp_path: Path + ) -> None: + runner.queue(exit_code=0, stdout=error_payload("not_git_worktree")) + + assert provider.find(tmp_path) is None + + def test_import_existing_is_idempotent( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + wt = repo / BRANCH + wt.mkdir(parents=True) + runner.queue(stdout=list_payload(repo, entry(wt))) + runner.queue(stdout=list_payload(repo, entry(wt))) + + assert provider.import_existing(wt) == provider.import_existing(wt) + + def test_import_existing_raises_for_unregistered( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + stranger = repo / "stranger" + stranger.mkdir() + runner.queue(stdout=list_payload(repo)) + + with pytest.raises(WorktreeNotFoundError): + provider.import_existing(stranger) + + +class TestRemove: + def test_removes_through_herdr_when_workspace_open( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + wt = repo / BRANCH + wt.mkdir(parents=True) + runner.queue(stdout=list_payload(repo, entry(wt, workspace_id="w8"))) # find + runner.queue(stdout=json.dumps({"result": {"type": "worktree_removed"}})) + + provider.remove(provider_worktree(repo, wt), force=True) + + assert runner.last_call.args == ( + HERDR, + "worktree", + "remove", + "--workspace", + "w8", + "--force", + "--json", + ) + + def test_falls_back_to_git_when_not_presented( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + wt = repo / BRANCH + wt.mkdir(parents=True) + runner.queue(stdout=list_payload(repo, entry(wt))) # find: no workspace id + + provider.remove(provider_worktree(repo, wt)) + + assert runner.last_call.args == ("git", "worktree", "remove", str(wt.resolve())) + + def test_reports_partial_failure_when_worktree_survives( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + wt = repo / BRANCH + wt.mkdir(parents=True) + runner.queue(stdout=list_payload(repo, entry(wt, workspace_id="w8"))) # find + runner.queue(stdout=error_payload("remove_failed")) # herdr remove fails + runner.queue(stdout=list_payload(repo)) # re-find: no longer registered + + with pytest.raises(WorktreeRemovalError, match="still exists"): + provider.remove(provider_worktree(repo, wt)) + + def test_raises_for_unregistered_worktree( + self, provider: HerdrWorktreeProvider, runner: FakeCommandRunner, repo: Path + ) -> None: + wt = repo / BRANCH + wt.mkdir(parents=True) + runner.queue(stdout=list_payload(repo)) + + with pytest.raises(WorktreeNotFoundError): + provider.remove(provider_worktree(repo, wt)) + + +def provider_worktree(repo: Path, wt: Path): + from git_workspace.workspace.models import ManagedWorktree + + return ManagedWorktree( + repository_path=repo, + worktree_path=wt, + branch=BRANCH, + provider_kind=ProviderKind.HERDR, + ) diff --git a/tests/unit/test_hook_runner.py b/tests/unit/test_hook_runner.py index e24c88d..ad62289 100644 --- a/tests/unit/test_hook_runner.py +++ b/tests/unit/test_hook_runner.py @@ -4,8 +4,8 @@ import pytest from pytest_mock import MockerFixture -from git_workspace.hooks import HookCommandResolver, HookNamesResolver, HookRunner from git_workspace.manifest import HookConditions, HookGroup +from git_workspace.workspace.hooks import HookCommandResolver, HookNamesResolver, HookRunner BRANCH = "feat/GWS-001" WORKSPACE_DIR = Path("/workspace") @@ -53,7 +53,7 @@ def mock_bin_is_file(mocker: MockerFixture) -> MagicMock: @pytest.fixture(autouse=True) def mock_popen(mocker: MockerFixture) -> MagicMock: - mock = mocker.patch("git_workspace.hooks.subprocess.Popen") + mock = mocker.patch("git_workspace.workspace.hooks.subprocess.Popen") mock.return_value.__enter__.return_value.returncode = 0 return mock diff --git a/tests/unit/test_ignore_manager.py b/tests/unit/test_ignore_manager.py index 10d3837..1456e55 100644 --- a/tests/unit/test_ignore_manager.py +++ b/tests/unit/test_ignore_manager.py @@ -4,7 +4,7 @@ import pytest from pytest_mock import MockerFixture -from git_workspace.assets import IgnoreManager +from git_workspace.workspace.assets import IgnoreManager @pytest.fixture diff --git a/tests/unit/test_linker.py b/tests/unit/test_linker.py index d3e1921..a40aba3 100644 --- a/tests/unit/test_linker.py +++ b/tests/unit/test_linker.py @@ -5,9 +5,9 @@ from pyfakefs.fake_filesystem import FakeFilesystem from pytest_mock import MockerFixture -from git_workspace.assets import IgnoreManager, Linker from git_workspace.errors import WorkspaceLinkError from git_workspace.manifest import Link +from git_workspace.workspace.assets import IgnoreManager, Linker ASSETS_DIR = Path("/workspace/.workspace/assets") WORKTREE_DIR = Path("/workspace/feat/GWS-001") @@ -49,7 +49,7 @@ def target(self) -> MagicMock: @pytest.fixture(autouse=True) def mock_git_skip_worktree(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.assets.git.skip_worktree") + return mocker.patch("git_workspace.workspace.assets.git.skip_worktree") def test_apply_calls_skip_worktree_for_override( self, @@ -179,7 +179,7 @@ def filesystem(self, fs: FakeFilesystem) -> None: @pytest.fixture(autouse=True) def mock_git_skip_worktree(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.assets.git.skip_worktree") + return mocker.patch("git_workspace.workspace.assets.git.skip_worktree") @pytest.fixture def linker(self, mocker: MockerFixture) -> Linker: diff --git a/tests/unit/test_lock.py b/tests/unit/test_lock.py new file mode 100644 index 0000000..21a8e22 --- /dev/null +++ b/tests/unit/test_lock.py @@ -0,0 +1,58 @@ +import fcntl +import os +from pathlib import Path + +import pytest + +from git_workspace.errors import WorkspaceLockedError +from git_workspace.workspace.lock import workspace_operation_lock +from git_workspace.workspace.state import state_file_stem + + +@pytest.fixture +def locks_dir(tmp_path: Path) -> Path: + return tmp_path / "locks" + + +@pytest.fixture +def worktree_path(tmp_path: Path) -> Path: + return tmp_path / "workspace" / "feature" / "auth" + + +class TestWorkspaceOperationLock: + def test_acquires_and_releases(self, locks_dir: Path, worktree_path: Path) -> None: + with workspace_operation_lock(locks_dir, worktree_path): + pass + + with workspace_operation_lock(locks_dir, worktree_path): + pass + + def test_fails_fast_when_already_held(self, locks_dir: Path, worktree_path: Path) -> None: + lock_path = locks_dir / f"{state_file_stem(worktree_path)}.lock" + locks_dir.mkdir(parents=True) + + # Hold the flock on a separate fd, as a concurrent process would. + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + try: + with pytest.raises(WorkspaceLockedError) as excinfo: + with workspace_operation_lock(locks_dir, worktree_path): + pass + + assert str(worktree_path) in str(excinfo.value) + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + def test_different_worktrees_do_not_contend(self, locks_dir: Path, tmp_path: Path) -> None: + with workspace_operation_lock(locks_dir, tmp_path / "a"): + with workspace_operation_lock(locks_dir, tmp_path / "b"): + pass + + def test_releases_lock_on_exception(self, locks_dir: Path, worktree_path: Path) -> None: + with pytest.raises(RuntimeError): + with workspace_operation_lock(locks_dir, worktree_path): + raise RuntimeError("boom") + + with workspace_operation_lock(locks_dir, worktree_path): + pass diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py new file mode 100644 index 0000000..b22a003 --- /dev/null +++ b/tests/unit/test_main.py @@ -0,0 +1,30 @@ +import pytest + +from git_workspace import main +from git_workspace.errors import GitWorkspaceError + + +class TestMain: + def test_exits_non_zero_on_domain_error(self, mocker): + mocker.patch("git_workspace.cli.app", side_effect=GitWorkspaceError("boom")) + + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == 1 + + def test_returns_normally_on_success(self, mocker): + mocker.patch("git_workspace.cli.app", return_value=None) + + main() + + def test_exits_with_tempfail_code_on_lock_contention(self, mocker): + from git_workspace import EXIT_CODE_LOCKED + from git_workspace.errors import WorkspaceLockedError + + mocker.patch("git_workspace.cli.app", side_effect=WorkspaceLockedError("locked")) + + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == EXIT_CODE_LOCKED diff --git a/tests/unit/test_manifest.py b/tests/unit/test_manifest.py index 2f1e7fa..db859ac 100644 --- a/tests/unit/test_manifest.py +++ b/tests/unit/test_manifest.py @@ -3,7 +3,6 @@ import pytest from pytest_mock import MockerFixture -from git_workspace.fingerprint import DEFAULT_ALGORITHM, DEFAULT_LENGTH from git_workspace.manifest import ( Fingerprint, HookGroup, @@ -11,7 +10,9 @@ Link, Manifest, Prune, + WorkspaceSettings, ) +from git_workspace.workspace.fingerprint import DEFAULT_ALGORITHM, DEFAULT_LENGTH @pytest.fixture @@ -338,3 +339,40 @@ def test_returns_empty_fingerprints_when_absent_from_toml(self, workspace: Magic result = Manifest.load(workspace) assert result.fingerprints == [] + + +class TestParseWorkspaceSettings: + def test_parses_workspace_table(self, workspace: MagicMock) -> None: + workspace.paths.manifest.read_text.return_value = """ +[workspace] +backend = "herdr" +provider = "native-git" +presenter = "none" +""" + + result = Manifest.load(workspace) + + assert result.workspace == WorkspaceSettings( + backend="herdr", + provider="native-git", + presenter="none", + ) + + def test_defaults_to_empty_settings_when_absent(self, workspace: MagicMock) -> None: + workspace.paths.manifest.read_text.return_value = "" + + result = Manifest.load(workspace) + + assert result.workspace == WorkspaceSettings() + + def test_partial_settings_leave_other_fields_none(self, workspace: MagicMock) -> None: + workspace.paths.manifest.read_text.return_value = """ +[workspace] +backend = "auto" +""" + + result = Manifest.load(workspace) + + assert result.workspace.backend == "auto" + assert result.workspace.provider is None + assert result.workspace.presenter is None diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..ebfb6fa --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,95 @@ +import dataclasses +from pathlib import Path + +import pytest + +from git_workspace.workspace.models import ( + ManagedWorktree, + ProviderKind, + WorkspaceLifecycleState, + WorkspaceRecord, + WorktreeRequest, +) + + +class TestWorktreeRequest: + def test_normalizes_repository_and_target_paths(self, tmp_path: Path) -> None: + request = WorktreeRequest( + repository_path=tmp_path / ".." / tmp_path.name, + branch="feature/x", + target_path=tmp_path / "sub" / ".." / "wt", + ) + + assert request.repository_path == tmp_path.resolve() + assert request.target_path == (tmp_path / "wt").resolve() + + def test_expands_user_home(self) -> None: + request = WorktreeRequest(repository_path=Path("~/repo"), branch="main") + + assert not str(request.repository_path).startswith("~") + + def test_is_frozen(self, tmp_path: Path) -> None: + request = WorktreeRequest(repository_path=tmp_path, branch="main") + + with pytest.raises(dataclasses.FrozenInstanceError): + request.branch = "other" # ty: ignore[invalid-assignment] + + +class TestManagedWorktree: + def test_normalizes_paths(self, tmp_path: Path) -> None: + worktree = ManagedWorktree( + repository_path=tmp_path / ".", + worktree_path=tmp_path / "a" / ".." / "wt", + branch="main", + provider_kind=ProviderKind.NATIVE_GIT, + ) + + assert worktree.repository_path == tmp_path.resolve() + assert worktree.worktree_path == (tmp_path / "wt").resolve() + + def test_is_frozen(self, tmp_path: Path) -> None: + worktree = ManagedWorktree( + repository_path=tmp_path, + worktree_path=tmp_path / "wt", + branch="main", + provider_kind=ProviderKind.NATIVE_GIT, + ) + + with pytest.raises(dataclasses.FrozenInstanceError): + worktree.branch = "other" # ty: ignore[invalid-assignment] + + +class TestWorkspaceRecord: + def test_holds_lifecycle_state_and_optional_error(self, tmp_path: Path) -> None: + worktree = ManagedWorktree( + repository_path=tmp_path, + worktree_path=tmp_path / "wt", + branch="main", + provider_kind=ProviderKind.NATIVE_GIT, + ) + + record = WorkspaceRecord( + worktree=worktree, + presentation=None, + lifecycle_state=WorkspaceLifecycleState.PREPARATION_FAILED, + preparation_error="hook failed", + ) + + assert record.lifecycle_state is WorkspaceLifecycleState.PREPARATION_FAILED + assert record.preparation_error == "hook failed" + + +class TestEnums: + def test_provider_kind_values(self) -> None: + assert ProviderKind.NATIVE_GIT.value == "native-git" + + def test_lifecycle_states_are_stable_identifiers(self) -> None: + assert [state.value for state in WorkspaceLifecycleState] == [ + "created", + "preparing", + "ready", + "preparation-failed", + "detached", + "tearing-down", + "removed", + ] diff --git a/tests/unit/test_native_git_provider.py b/tests/unit/test_native_git_provider.py new file mode 100644 index 0000000..a9234b5 --- /dev/null +++ b/tests/unit/test_native_git_provider.py @@ -0,0 +1,342 @@ +from pathlib import Path +from typing import Any + +import pytest + +from git_workspace.errors import ( + WorktreeAlreadyExistsError, + WorktreeCreationError, + WorktreeNotFoundError, +) +from git_workspace.providers.base import WorktreeProvider +from git_workspace.providers.native_git import NativeGitProvider +from git_workspace.workspace.models import ManagedWorktree, ProviderKind, WorktreeRequest +from tests.helpers import FakeCommandRunner + +BRANCH = "feat/GWS-001" +BASE_BRANCH = "main" +REPO = Path("/workspace") +TARGET = Path("/workspace/feat/GWS-001") +COMMIT_SHA = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + + +@pytest.fixture +def runner() -> FakeCommandRunner: + return FakeCommandRunner() + + +@pytest.fixture +def provider(runner: FakeCommandRunner) -> NativeGitProvider: + return NativeGitProvider(runner) + + +def request(**overrides) -> WorktreeRequest: + defaults: dict[str, Any] = { + "repository_path": REPO, + "branch": BRANCH, + "base_branch": BASE_BRANCH, + "target_path": TARGET, + } + defaults.update(overrides) + return WorktreeRequest(**defaults) + + +def porcelain(directory: Path = TARGET, branch: str = BRANCH) -> str: + return f"worktree {directory}\nHEAD {COMMIT_SHA}\nbranch refs/heads/{branch}" + + +class TestProtocol: + def test_satisfies_worktree_provider_protocol(self, provider: NativeGitProvider) -> None: + assert isinstance(provider, WorktreeProvider) + + def test_kind_is_native_git(self, provider: NativeGitProvider) -> None: + assert provider.kind is ProviderKind.NATIVE_GIT + + def test_is_available_when_git_on_path(self, provider: NativeGitProvider, mocker) -> None: + mocker.patch("git_workspace.providers.native_git.shutil.which", return_value="/usr/bin/git") + + assert provider.is_available() is True + + def test_is_unavailable_without_git(self, provider: NativeGitProvider, mocker) -> None: + mocker.patch("git_workspace.providers.native_git.shutil.which", return_value=None) + + assert provider.is_available() is False + + +class TestCreate: + def test_creates_from_local_branch_when_it_exists( + self, provider: NativeGitProvider, runner: FakeCommandRunner + ) -> None: + runner.queue(exit_code=0, stdout="") # worktree list: no conflict + runner.queue(exit_code=0) # local branch exists + + result = provider.create(request()) + + assert runner.calls[1].args == ( + "git", + "rev-parse", + "--verify", + "--quiet", + f"refs/heads/{BRANCH}", + ) + assert runner.last_call.args == ("git", "worktree", "add", str(TARGET), BRANCH) + assert runner.last_call.cwd == REPO + assert result == ManagedWorktree( + repository_path=REPO, + worktree_path=TARGET, + branch=BRANCH, + provider_kind=ProviderKind.NATIVE_GIT, + ) + + def test_creates_tracking_worktree_from_remote_branch( + self, provider: NativeGitProvider, runner: FakeCommandRunner + ) -> None: + runner.queue(exit_code=0, stdout="") # worktree list + runner.queue(exit_code=1) # local branch missing + runner.queue(exit_code=0) # fetch + runner.queue(exit_code=0) # remote branch exists + + provider.create(request()) + + assert runner.calls[2].args == ("git", "fetch", "origin", "--prune") + assert runner.last_call.args == ( + "git", + "worktree", + "add", + "--track", + "-b", + BRANCH, + str(TARGET), + f"origin/{BRANCH}", + ) + + def test_creates_new_branch_preferring_remote_base( + self, provider: NativeGitProvider, runner: FakeCommandRunner + ) -> None: + runner.queue(exit_code=0, stdout="") # worktree list + runner.queue(exit_code=1) # local branch missing + runner.queue(exit_code=0) # fetch + runner.queue(exit_code=1) # remote branch missing + runner.queue(exit_code=0) # remote base exists + + provider.create(request()) + + assert runner.last_call.args == ( + "git", + "worktree", + "add", + "-b", + BRANCH, + str(TARGET), + f"origin/{BASE_BRANCH}", + ) + + def test_creates_new_branch_from_local_base_when_remote_base_missing( + self, provider: NativeGitProvider, runner: FakeCommandRunner + ) -> None: + runner.queue(exit_code=0, stdout="") # worktree list + runner.queue(exit_code=1) # local branch missing + runner.queue(exit_code=0) # fetch + runner.queue(exit_code=1) # remote branch missing + runner.queue(exit_code=1) # remote base missing + + provider.create(request()) + + assert runner.last_call.args == ( + "git", + "worktree", + "add", + "-b", + BRANCH, + str(TARGET), + BASE_BRANCH, + ) + + def test_skips_remote_branch_lookup_when_fetch_fails( + self, provider: NativeGitProvider, runner: FakeCommandRunner + ) -> None: + runner.queue(exit_code=0, stdout="") # worktree list + runner.queue(exit_code=1) # local branch missing + runner.queue(exit_code=1) # fetch fails + runner.queue(exit_code=1) # remote base missing + + provider.create(request()) + + assert runner.calls[3].args == ( + "git", + "rev-parse", + "--verify", + "--quiet", + f"refs/remotes/origin/{BASE_BRANCH}", + ) + assert runner.last_call.args == ( + "git", + "worktree", + "add", + "-b", + BRANCH, + str(TARGET), + BASE_BRANCH, + ) + + def test_raises_when_target_already_registered( + self, provider: NativeGitProvider, runner: FakeCommandRunner + ) -> None: + runner.queue(exit_code=0, stdout=porcelain()) + + with pytest.raises(WorktreeAlreadyExistsError): + provider.create(request()) + + def test_raises_when_branch_missing_and_creation_not_requested( + self, provider: NativeGitProvider, runner: FakeCommandRunner + ) -> None: + runner.queue(exit_code=0, stdout="") # worktree list + runner.queue(exit_code=1) # local branch missing + runner.queue(exit_code=0) # fetch + runner.queue(exit_code=1) # remote branch missing + + with pytest.raises(WorktreeCreationError): + provider.create(request(create_branch=False)) + + def test_raises_when_base_branch_missing_for_new_branch( + self, provider: NativeGitProvider, runner: FakeCommandRunner + ) -> None: + runner.queue(exit_code=0, stdout="") # worktree list + runner.queue(exit_code=1) # local branch missing + runner.queue(exit_code=0) # fetch + runner.queue(exit_code=1) # remote branch missing + + with pytest.raises(WorktreeCreationError): + provider.create(request(base_branch=None)) + + def test_derives_target_from_repository_and_branch_when_omitted( + self, provider: NativeGitProvider, runner: FakeCommandRunner + ) -> None: + runner.queue(exit_code=0, stdout="") # worktree list + runner.queue(exit_code=0) # local branch exists + + result = provider.create(request(target_path=None)) + + assert result.worktree_path == (REPO / BRANCH).resolve() + + +class TestList: + def test_parses_porcelain_into_managed_worktrees( + self, provider: NativeGitProvider, runner: FakeCommandRunner + ) -> None: + runner.queue(exit_code=0, stdout=porcelain()) + + result = provider.list(REPO) + + assert runner.last_call.args == ("git", "worktree", "list", "--porcelain") + assert runner.last_call.cwd == REPO + assert result == [ + ManagedWorktree( + repository_path=REPO, + worktree_path=TARGET, + branch=BRANCH, + provider_kind=ProviderKind.NATIVE_GIT, + ) + ] + + def test_requires_a_repository_path(self, provider: NativeGitProvider) -> None: + with pytest.raises(WorktreeNotFoundError): + provider.list(None) + + +class TestFind: + def test_returns_none_for_missing_directory(self, provider: NativeGitProvider) -> None: + assert provider.find(Path("/nonexistent/worktree")) is None + + def test_returns_none_outside_a_repository( + self, provider: NativeGitProvider, runner: FakeCommandRunner, tmp_path: Path + ) -> None: + runner.queue(exit_code=128) + + assert provider.find(tmp_path) is None + + def test_finds_worktree_by_canonical_path( + self, provider: NativeGitProvider, runner: FakeCommandRunner, tmp_path: Path + ) -> None: + repo = tmp_path + worktree_dir = tmp_path / "wt" + worktree_dir.mkdir() + runner.queue(exit_code=0, stdout=str(repo / ".git")) + runner.queue(exit_code=0, stdout=porcelain(directory=worktree_dir)) + + result = provider.find(worktree_dir) + + assert result is not None + assert result.worktree_path == worktree_dir.resolve() + assert result.repository_path == repo.resolve() + + def test_returns_none_when_path_not_registered( + self, provider: NativeGitProvider, runner: FakeCommandRunner, tmp_path: Path + ) -> None: + unregistered = tmp_path / "other" + unregistered.mkdir() + runner.queue(exit_code=0, stdout=str(tmp_path / ".git")) + runner.queue(exit_code=0, stdout=porcelain(directory=tmp_path / "wt")) + + assert provider.find(unregistered) is None + + +class TestImportExisting: + def test_returns_managed_worktree_when_registered( + self, provider: NativeGitProvider, runner: FakeCommandRunner, tmp_path: Path + ) -> None: + worktree_dir = tmp_path / "wt" + worktree_dir.mkdir() + runner.queue(exit_code=0, stdout=str(tmp_path / ".git")) + runner.queue(exit_code=0, stdout=porcelain(directory=worktree_dir)) + + result = provider.import_existing(worktree_dir) + + assert result.worktree_path == worktree_dir.resolve() + + def test_is_idempotent( + self, provider: NativeGitProvider, runner: FakeCommandRunner, tmp_path: Path + ) -> None: + worktree_dir = tmp_path / "wt" + worktree_dir.mkdir() + for _ in range(2): + runner.queue(exit_code=0, stdout=str(tmp_path / ".git")) + runner.queue(exit_code=0, stdout=porcelain(directory=worktree_dir)) + + first = provider.import_existing(worktree_dir) + second = provider.import_existing(worktree_dir) + + assert first == second + + def test_raises_for_unregistered_path( + self, provider: NativeGitProvider, runner: FakeCommandRunner, tmp_path: Path + ) -> None: + runner.queue(exit_code=128) + + with pytest.raises(WorktreeNotFoundError): + provider.import_existing(tmp_path) + + +class TestRemove: + def worktree(self) -> ManagedWorktree: + return ManagedWorktree( + repository_path=REPO, + worktree_path=TARGET, + branch=BRANCH, + provider_kind=ProviderKind.NATIVE_GIT, + ) + + def test_removes_without_force( + self, provider: NativeGitProvider, runner: FakeCommandRunner + ) -> None: + provider.remove(self.worktree()) + + assert runner.last_call.args == ("git", "worktree", "remove", str(TARGET)) + assert runner.last_call.cwd == REPO + + def test_removes_with_force( + self, provider: NativeGitProvider, runner: FakeCommandRunner + ) -> None: + provider.remove(self.worktree(), force=True) + + assert runner.last_call.args == ("git", "worktree", "remove", "--force", str(TARGET)) diff --git a/tests/unit/test_operations.py b/tests/unit/test_operations.py deleted file mode 100644 index 4ca43fb..0000000 --- a/tests/unit/test_operations.py +++ /dev/null @@ -1,203 +0,0 @@ -from unittest.mock import MagicMock - -import pytest -from pytest_mock import MockerFixture - -import git_workspace.operations as operations - -MOCK_ENV = {"GIT_WORKSPACE_BRANCH": "main"} - - -@pytest.fixture(autouse=True) -def mock_compute_fingerprints(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.operations.compute_fingerprints", return_value={}) - - -@pytest.fixture(autouse=True) -def mock_build_env(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.operations.build_env", return_value=MOCK_ENV) - - -@pytest.fixture(autouse=True) -def mock_ignore_manager(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.operations.IgnoreManager") - - -@pytest.fixture(autouse=True) -def mock_copier(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.operations.Copier") - - -@pytest.fixture(autouse=True) -def mock_linker(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.operations.Linker") - - -@pytest.fixture(autouse=True) -def mock_hook_runner(mocker: MockerFixture) -> MagicMock: - mock = mocker.patch("git_workspace.operations.HookRunner") - mock.return_value.__enter__.return_value = mock.return_value - return mock - - -@pytest.fixture -def worktree() -> MagicMock: - wt = MagicMock() - wt.is_new = False - return wt - - -class TestApplyAssetsInternal: - def test_applies_copier_and_linker_via_ignore_manager( - self, - worktree: MagicMock, - mock_ignore_manager: MagicMock, - mock_copier: MagicMock, - mock_linker: MagicMock, - ) -> None: - operations._apply_assets(worktree, MOCK_ENV) - - ignore = mock_ignore_manager.return_value.__enter__.return_value - mock_copier.assert_called_once_with(worktree, ignore, MOCK_ENV) - mock_copier.return_value.apply.assert_called_once() - mock_linker.assert_called_once_with(worktree, ignore) - mock_linker.return_value.apply.assert_called_once() - - -class TestActivateWorktree: - def test_new_attached_applies_assets_and_runs_setup_and_attach_hooks( - self, - worktree: MagicMock, - mock_copier: MagicMock, - mock_linker: MagicMock, - mock_hook_runner: MagicMock, - ) -> None: - worktree.is_new = True - - operations.activate_worktree(worktree, runtime_vars={}, detached=False) - - mock_copier.return_value.apply.assert_called_once() - mock_linker.return_value.apply.assert_called_once() - mock_hook_runner.return_value.run_on_setup_hooks.assert_called_once() - mock_hook_runner.return_value.run_on_attach_hooks.assert_called_once() - - def test_new_detached_applies_assets_and_skips_attach_hooks( - self, - worktree: MagicMock, - mock_copier: MagicMock, - mock_linker: MagicMock, - mock_hook_runner: MagicMock, - ) -> None: - worktree.is_new = True - - operations.activate_worktree(worktree, runtime_vars={}, detached=True) - - mock_copier.return_value.apply.assert_called_once() - mock_linker.return_value.apply.assert_called_once() - mock_hook_runner.return_value.run_on_setup_hooks.assert_called_once() - mock_hook_runner.return_value.run_on_attach_hooks.assert_not_called() - - def test_existing_attached_skips_assets_and_setup_hooks( - self, - worktree: MagicMock, - mock_copier: MagicMock, - mock_hook_runner: MagicMock, - ) -> None: - worktree.is_new = False - - operations.activate_worktree(worktree, runtime_vars={}, detached=False) - - mock_copier.assert_not_called() - mock_hook_runner.return_value.run_on_setup_hooks.assert_not_called() - mock_hook_runner.return_value.run_on_attach_hooks.assert_called_once() - - def test_existing_detached_skips_all_hooks( - self, - worktree: MagicMock, - mock_copier: MagicMock, - mock_hook_runner: MagicMock, - ) -> None: - worktree.is_new = False - - operations.activate_worktree(worktree, runtime_vars={}, detached=True) - - mock_copier.assert_not_called() - mock_hook_runner.return_value.run_on_setup_hooks.assert_not_called() - mock_hook_runner.return_value.run_on_attach_hooks.assert_not_called() - - def test_passes_env_to_hook_runner( - self, - worktree: MagicMock, - mock_hook_runner: MagicMock, - ) -> None: - operations.activate_worktree(worktree, runtime_vars={"KEY": "val"}, detached=False) - mock_hook_runner.assert_called_once_with( - worktree, env=MOCK_ENV, effective_branch=worktree.branch - ) - - -class TestResetWorktree: - def test_always_applies_assets_and_runs_setup_hooks( - self, - worktree: MagicMock, - mock_copier: MagicMock, - mock_linker: MagicMock, - mock_hook_runner: MagicMock, - ) -> None: - operations.reset_worktree(worktree, runtime_vars={}) - - mock_copier.return_value.apply.assert_called_once() - mock_linker.return_value.apply.assert_called_once() - mock_hook_runner.return_value.run_on_setup_hooks.assert_called_once() - - def test_does_not_run_attach_hooks( - self, - worktree: MagicMock, - mock_hook_runner: MagicMock, - ) -> None: - operations.reset_worktree(worktree, runtime_vars={}) - - mock_hook_runner.return_value.run_on_attach_hooks.assert_not_called() - - -class TestDeactivateWorktree: - def test_runs_detach_hooks( - self, - worktree: MagicMock, - mock_hook_runner: MagicMock, - ) -> None: - operations.deactivate_worktree(worktree, runtime_vars={}) - - mock_hook_runner.return_value.run_on_detach_hooks.assert_called_once() - - def test_does_not_run_other_hooks( - self, - worktree: MagicMock, - mock_hook_runner: MagicMock, - ) -> None: - operations.deactivate_worktree(worktree, runtime_vars={}) - - mock_hook_runner.return_value.run_on_setup_hooks.assert_not_called() - mock_hook_runner.return_value.run_on_attach_hooks.assert_not_called() - mock_hook_runner.return_value.run_on_teardown_hooks.assert_not_called() - - -class TestRemoveWorktree: - def test_runs_detach_and_teardown_hooks_then_deletes( - self, - worktree: MagicMock, - mock_hook_runner: MagicMock, - ) -> None: - operations.remove_worktree(worktree, runtime_vars={}, force=False) - - mock_hook_runner.return_value.run_on_detach_hooks.assert_called_once() - mock_hook_runner.return_value.run_on_teardown_hooks.assert_called_once() - worktree.delete.assert_called_once_with(False) - - def test_passes_force_to_delete( - self, - worktree: MagicMock, - ) -> None: - operations.remove_worktree(worktree, runtime_vars={}, force=True) - - worktree.delete.assert_called_once_with(True) diff --git a/tests/unit/test_preparer.py b/tests/unit/test_preparer.py new file mode 100644 index 0000000..7521616 --- /dev/null +++ b/tests/unit/test_preparer.py @@ -0,0 +1,146 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from git_workspace.workspace.preparer import WorkspacePreparer + +MOCK_ENV = {"GIT_WORKSPACE_BRANCH": "main"} +WORKTREE_DIR = Path("/workspace/main") +BRANCH = "main" + + +@pytest.fixture(autouse=True) +def mock_compute_fingerprints(mocker: MockerFixture) -> MagicMock: + return mocker.patch("git_workspace.workspace.preparer.compute_fingerprints", return_value={}) + + +@pytest.fixture(autouse=True) +def mock_build_env(mocker: MockerFixture) -> MagicMock: + return mocker.patch("git_workspace.workspace.preparer.build_env", return_value=MOCK_ENV) + + +@pytest.fixture(autouse=True) +def mock_ignore_manager(mocker: MockerFixture) -> MagicMock: + return mocker.patch("git_workspace.workspace.preparer.IgnoreManager") + + +@pytest.fixture(autouse=True) +def mock_copier(mocker: MockerFixture) -> MagicMock: + return mocker.patch("git_workspace.workspace.preparer.Copier") + + +@pytest.fixture(autouse=True) +def mock_linker(mocker: MockerFixture) -> MagicMock: + return mocker.patch("git_workspace.workspace.preparer.Linker") + + +@pytest.fixture(autouse=True) +def mock_hook_runner(mocker: MockerFixture) -> MagicMock: + mock = mocker.patch("git_workspace.workspace.preparer.HookRunner") + mock.return_value.__enter__.return_value = mock.return_value + return mock + + +@pytest.fixture +def workspace() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def preparer(workspace: MagicMock) -> WorkspacePreparer: + return WorkspacePreparer(workspace) + + +class TestPrepare: + def test_applies_assets_and_runs_setup_hooks( + self, + preparer: WorkspacePreparer, + mock_ignore_manager: MagicMock, + mock_copier: MagicMock, + mock_linker: MagicMock, + mock_hook_runner: MagicMock, + ) -> None: + preparer.prepare(WORKTREE_DIR, BRANCH) + + ignore = mock_ignore_manager.return_value.__enter__.return_value + worktree = mock_copier.call_args.args[0] + mock_copier.assert_called_once_with(worktree, ignore, MOCK_ENV) + mock_copier.return_value.apply.assert_called_once() + mock_linker.assert_called_once_with(worktree, ignore) + mock_linker.return_value.apply.assert_called_once() + mock_hook_runner.return_value.run_on_setup_hooks.assert_called_once() + + def test_does_not_run_attach_hooks( + self, preparer: WorkspacePreparer, mock_hook_runner: MagicMock + ) -> None: + preparer.prepare(WORKTREE_DIR, BRANCH) + + mock_hook_runner.return_value.run_on_attach_hooks.assert_not_called() + + def test_builds_context_from_path_and_branch( + self, preparer: WorkspacePreparer, workspace: MagicMock, mock_hook_runner: MagicMock + ) -> None: + preparer.prepare(WORKTREE_DIR, BRANCH) + + worktree = mock_hook_runner.call_args.args[0] + assert worktree.workspace is workspace + assert worktree.dir == WORKTREE_DIR + assert worktree.branch == BRANCH + + def test_passes_env_and_effective_branch_to_hook_runner( + self, preparer: WorkspacePreparer, mock_hook_runner: MagicMock + ) -> None: + preparer.prepare(WORKTREE_DIR, BRANCH) + + assert mock_hook_runner.call_args.kwargs["env"] == MOCK_ENV + assert mock_hook_runner.call_args.kwargs["effective_branch"] == BRANCH + + def test_effective_branch_overrides_hook_conditions( + self, preparer: WorkspacePreparer, mock_hook_runner: MagicMock + ) -> None: + preparer.prepare(WORKTREE_DIR, BRANCH, effective_branch="release/x") + + assert mock_hook_runner.call_args.kwargs["effective_branch"] == "release/x" + + def test_forwards_runtime_vars_to_env( + self, preparer: WorkspacePreparer, mock_build_env: MagicMock + ) -> None: + preparer.prepare(WORKTREE_DIR, BRANCH, runtime_vars={"KEY": "val"}) + + assert mock_build_env.call_args.args[1] == {"KEY": "val"} + + +class TestAttach: + def test_runs_only_attach_hooks( + self, preparer: WorkspacePreparer, mock_copier: MagicMock, mock_hook_runner: MagicMock + ) -> None: + preparer.attach(WORKTREE_DIR, BRANCH) + + mock_copier.assert_not_called() + mock_hook_runner.return_value.run_on_attach_hooks.assert_called_once() + mock_hook_runner.return_value.run_on_setup_hooks.assert_not_called() + + +class TestDetach: + def test_runs_only_detach_hooks( + self, preparer: WorkspacePreparer, mock_hook_runner: MagicMock + ) -> None: + preparer.detach(WORKTREE_DIR, BRANCH) + + mock_hook_runner.return_value.run_on_detach_hooks.assert_called_once() + mock_hook_runner.return_value.run_on_setup_hooks.assert_not_called() + mock_hook_runner.return_value.run_on_attach_hooks.assert_not_called() + mock_hook_runner.return_value.run_on_teardown_hooks.assert_not_called() + + +class TestTeardown: + def test_runs_only_teardown_hooks( + self, preparer: WorkspacePreparer, mock_copier: MagicMock, mock_hook_runner: MagicMock + ) -> None: + preparer.teardown(WORKTREE_DIR, BRANCH) + + mock_copier.assert_not_called() + mock_hook_runner.return_value.run_on_teardown_hooks.assert_called_once() + mock_hook_runner.return_value.run_on_detach_hooks.assert_not_called() diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py new file mode 100644 index 0000000..6e50e5f --- /dev/null +++ b/tests/unit/test_runner.py @@ -0,0 +1,61 @@ +import sys + +import pytest + +from git_workspace.errors import ExternalCommandError +from git_workspace.subprocesses.runner import CommandRunner, SubprocessCommandRunner + + +class TestSubprocessCommandRunner: + def test_satisfies_protocol(self) -> None: + assert isinstance(SubprocessCommandRunner(), CommandRunner) + + def test_captures_stdout_and_exit_code(self) -> None: + result = SubprocessCommandRunner().run([sys.executable, "-c", "print('hello')"]) + + assert result.ok + assert result.exit_code == 0 + assert result.stdout == "hello\n" + assert result.args == (sys.executable, "-c", "print('hello')") + + def test_captures_stderr(self) -> None: + result = SubprocessCommandRunner().run( + [sys.executable, "-c", "import sys; sys.stderr.write('oops')"], + ) + + assert result.stderr == "oops" + + def test_check_true_raises_on_failure(self) -> None: + with pytest.raises(ExternalCommandError) as excinfo: + SubprocessCommandRunner().run( + [sys.executable, "-c", "import sys; sys.stderr.write('bad'); sys.exit(3)"], + ) + + assert excinfo.value.exit_code == 3 + assert excinfo.value.stderr == "bad" + assert "exit code 3" in str(excinfo.value) + + def test_check_false_returns_failure_result(self) -> None: + result = SubprocessCommandRunner().run( + [sys.executable, "-c", "import sys; sys.exit(2)"], + check=False, + ) + + assert not result.ok + assert result.exit_code == 2 + + def test_passes_cwd(self, tmp_path) -> None: + result = SubprocessCommandRunner().run( + [sys.executable, "-c", "import os; print(os.getcwd())"], + cwd=tmp_path, + ) + + assert result.stdout.strip() == str(tmp_path.resolve()) + + def test_passes_env(self) -> None: + result = SubprocessCommandRunner().run( + [sys.executable, "-c", "import os; print(os.environ.get('GW_TEST_VAR', ''))"], + env={"GW_TEST_VAR": "value"}, + ) + + assert result.stdout.strip() == "value" diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py new file mode 100644 index 0000000..13bf583 --- /dev/null +++ b/tests/unit/test_service.py @@ -0,0 +1,690 @@ +import builtins +import fcntl +import os +from dataclasses import dataclass, field +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from git_workspace.backends.models import WorkspaceBackend +from git_workspace.errors import ( + HookExecutionError, + WorkspaceLockedError, + WorkspacePreparationError, + WorktreeNotFoundError, +) +from git_workspace.workspace.models import ( + ManagedWorktree, + Presentation, + PresenterCapabilities, + PresenterKind, + ProviderKind, + WorkspaceLifecycleState, + WorktreeRequest, +) +from git_workspace.workspace.service import WorkspaceService +from git_workspace.workspace.state import WorkspaceStateStore, state_file_stem + +BRANCH = "feature/auth" +BASE_BRANCH = "main" + + +class FakeProvider: + kind = ProviderKind.NATIVE_GIT + + def __init__(self, repository_path: Path) -> None: + self.repository_path = repository_path + self.worktrees: list[ManagedWorktree] = [] + self.created: list[WorktreeRequest] = [] + self.removed: list[tuple[ManagedWorktree, bool]] = [] + + def register(self, worktree_path: Path, branch: str) -> ManagedWorktree: + worktree_path.mkdir(parents=True, exist_ok=True) + managed = ManagedWorktree( + repository_path=self.repository_path, + worktree_path=worktree_path, + branch=branch, + provider_kind=ProviderKind.NATIVE_GIT, + ) + self.worktrees.append(managed) + return managed + + def is_available(self) -> bool: + return True + + def create(self, request: WorktreeRequest) -> ManagedWorktree: + self.created.append(request) + assert request.target_path is not None + return self.register(request.target_path, request.branch) + + def import_existing(self, worktree_path: Path) -> ManagedWorktree: + found = self.find(worktree_path) + if found is None: + raise WorktreeNotFoundError(f"No registered git worktree found at {worktree_path}") + return found + + def find(self, worktree_path: Path) -> ManagedWorktree | None: + canonical = worktree_path.expanduser().resolve() + return next((wt for wt in self.worktrees if wt.worktree_path == canonical), None) + + def list(self, repository_path: Path | None = None) -> builtins.list[ManagedWorktree]: + return [*self.worktrees] + + def remove(self, worktree: ManagedWorktree, *, force: bool = False) -> None: + self.removed.append((worktree, force)) + self.worktrees = [wt for wt in self.worktrees if wt != worktree] + + +@dataclass +class FakePreparer: + calls: list[tuple[str, Path, str]] = field(default_factory=list) + prepare_error: Exception | None = None + teardown_error: Exception | None = None + + def prepare(self, worktree_path, branch, *, runtime_vars=None, effective_branch=None): + self.calls.append(("prepare", worktree_path, branch)) + if self.prepare_error is not None: + raise self.prepare_error + + def attach(self, worktree_path, branch, *, runtime_vars=None, effective_branch=None): + self.calls.append(("attach", worktree_path, branch)) + + def detach(self, worktree_path, branch, *, runtime_vars=None, effective_branch=None): + self.calls.append(("detach", worktree_path, branch)) + + def teardown(self, worktree_path, branch, *, runtime_vars=None, effective_branch=None): + self.calls.append(("teardown", worktree_path, branch)) + if self.teardown_error is not None: + raise self.teardown_error + + def names(self) -> list[str]: + return [name for name, *_ in self.calls] + + +class FakePresenter: + kind = PresenterKind.NONE + + def __init__(self, *, can_close: bool = True, open_error: Exception | None = None) -> None: + self.opened: list[ManagedWorktree] = [] + self.focused: list[ManagedWorktree] = [] + self.closed: list[ManagedWorktree] = [] + self.open_error = open_error + self._can_close = can_close + + @property + def capabilities(self) -> PresenterCapabilities: + return PresenterCapabilities( + can_find_existing=True, + can_focus_existing=True, + can_close=self._can_close, + can_list=True, + supports_runtime_identity=True, + ) + + def is_available(self) -> bool: + return True + + def open(self, worktree: ManagedWorktree) -> Presentation: + if self.open_error is not None: + raise self.open_error + self.opened.append(worktree) + return Presentation(presenter_kind=PresenterKind.NONE, presentation_id="p-1") + + def find(self, worktree_path: Path) -> Presentation | None: + return None + + def focus(self, worktree, presentation=None) -> Presentation: + self.focused.append(worktree) + return presentation or Presentation(presenter_kind=PresenterKind.NONE) + + def close(self, worktree, presentation=None) -> None: + self.closed.append(worktree) + + def list(self, repository_path=None): + return [] + + +@pytest.fixture +def workspace_dir(tmp_path: Path) -> Path: + d = tmp_path / "workspace" + d.mkdir() + return d + + +@pytest.fixture +def provider(workspace_dir: Path) -> FakeProvider: + return FakeProvider(workspace_dir) + + +@pytest.fixture +def preparer() -> FakePreparer: + return FakePreparer() + + +@pytest.fixture +def store(workspace_dir: Path) -> WorkspaceStateStore: + return WorkspaceStateStore(workspace_dir / ".workspace" / ".state") + + +@pytest.fixture +def workspace(mocker: MockerFixture, workspace_dir: Path, provider: FakeProvider) -> MagicMock: + mock = mocker.MagicMock() + mock.dir = workspace_dir + mock.manifest.base_branch = BASE_BRANCH + mock.manifest.prune = None + mock.paths.worktree.side_effect = lambda branch: workspace_dir / branch + mock.paths.state = workspace_dir / ".workspace" / ".state" + mock.provider = provider + return mock + + +def make_service( + workspace: MagicMock, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + presenter: FakePresenter | None = None, +) -> WorkspaceService: + backend = WorkspaceBackend(name="native", provider=provider, presenter=presenter) + return WorkspaceService( + workspace=workspace, + backend=backend, + preparer=preparer, # ty:ignore[invalid-argument-type] + state_store=store, + ) + + +@pytest.fixture +def service( + workspace: MagicMock, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, +) -> WorkspaceService: + return make_service(workspace, provider, preparer, store) + + +class TestUpNew: + def test_creates_prepares_and_attaches( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + worktree = service.up(BRANCH) + + request = provider.created[0] + assert request.branch == BRANCH + assert request.base_branch == BASE_BRANCH + assert request.target_path == (workspace_dir / BRANCH).resolve() + assert preparer.names() == ["prepare", "attach"] + assert worktree.is_new is True + + record = store.load(worktree.dir) + assert record is not None + assert record.lifecycle_state is WorkspaceLifecycleState.READY + + def test_detached_skips_attach(self, service: WorkspaceService, preparer: FakePreparer) -> None: + service.up(BRANCH, detached=True) + + assert preparer.names() == ["prepare"] + + def test_uses_explicit_base_branch( + self, service: WorkspaceService, provider: FakeProvider + ) -> None: + service.up(BRANCH, base_branch="develop") + + assert provider.created[0].base_branch == "develop" + + def test_preparation_failure_preserves_worktree_and_records_state( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + preparer.prepare_error = HookExecutionError("hook exploded") + + with pytest.raises(WorkspacePreparationError) as excinfo: + service.up(BRANCH) + + assert provider.removed == [] + assert "prepare" in str(excinfo.value) + + record = store.load(workspace_dir / BRANCH) + assert record is not None + assert record.lifecycle_state is WorkspaceLifecycleState.PREPARATION_FAILED + assert record.preparation_error == "hook exploded" + + +class TestUpExisting: + def test_legacy_worktree_is_grandfathered_ready_without_prepare( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + provider.register(workspace_dir / BRANCH, BRANCH) + + service.up(BRANCH) + + assert preparer.names() == ["attach"] + record = store.load(workspace_dir / BRANCH) + assert record is not None + assert record.lifecycle_state is WorkspaceLifecycleState.READY + + def test_failed_preparation_is_retried_on_up( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + managed = provider.register(workspace_dir / BRANCH, BRANCH) + store.save_created(managed) + store.mark_preparation_failed(managed.worktree_path, error="boom") + + service.up(BRANCH) + + assert preparer.names() == ["prepare", "attach"] + record = store.load(managed.worktree_path) + assert record is not None + assert record.lifecycle_state is WorkspaceLifecycleState.READY + + def test_ready_worktree_only_attaches( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + managed = provider.register(workspace_dir / BRANCH, BRANCH) + store.save_created(managed) + store.set_state(managed.worktree_path, WorkspaceLifecycleState.READY) + + service.up(BRANCH) + + assert preparer.names() == ["attach"] + + +class TestPreparePath: + def test_rejects_unregistered_path(self, service: WorkspaceService, tmp_path: Path) -> None: + with pytest.raises(WorktreeNotFoundError): + service.prepare_path(tmp_path / "unknown") + + def test_prepares_worktree_without_state( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + managed = provider.register(workspace_dir / BRANCH, BRANCH) + + outcome = service.prepare_path(managed.worktree_path) + + assert outcome.skipped is False + assert preparer.names() == ["prepare"] + assert outcome.record.lifecycle_state is WorkspaceLifecycleState.READY + + def test_skips_when_already_ready( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + managed = provider.register(workspace_dir / BRANCH, BRANCH) + store.save_created(managed) + store.set_state(managed.worktree_path, WorkspaceLifecycleState.READY) + + outcome = service.prepare_path(managed.worktree_path) + + assert outcome.skipped is True + assert preparer.names() == [] + + def test_force_reruns_preparation( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + managed = provider.register(workspace_dir / BRANCH, BRANCH) + store.save_created(managed) + store.set_state(managed.worktree_path, WorkspaceLifecycleState.READY) + + outcome = service.prepare_path(managed.worktree_path, force=True) + + assert outcome.skipped is False + assert preparer.names() == ["prepare"] + + def test_retries_after_failure( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + managed = provider.register(workspace_dir / BRANCH, BRANCH) + preparer.prepare_error = HookExecutionError("boom") + with pytest.raises(WorkspacePreparationError): + service.prepare_path(managed.worktree_path) + + preparer.prepare_error = None + outcome = service.prepare_path(managed.worktree_path) + + assert outcome.record.lifecycle_state is WorkspaceLifecycleState.READY + + def test_never_runs_attach( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + workspace_dir: Path, + ) -> None: + managed = provider.register(workspace_dir / BRANCH, BRANCH) + + service.prepare_path(managed.worktree_path) + + assert "attach" not in preparer.names() + + +class TestDown: + def test_detaches_and_preserves_worktree( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + managed = provider.register(workspace_dir / BRANCH, BRANCH) + + service.down(BRANCH) + + assert preparer.names() == ["detach"] + assert provider.removed == [] + record = store.load(managed.worktree_path) + assert record is not None + assert record.lifecycle_state is WorkspaceLifecycleState.DETACHED + + def test_preserves_failed_preparation_state( + self, + service: WorkspaceService, + provider: FakeProvider, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + managed = provider.register(workspace_dir / BRANCH, BRANCH) + store.save_created(managed) + store.mark_preparation_failed(managed.worktree_path, error="boom") + + service.down(BRANCH) + + record = store.load(managed.worktree_path) + assert record is not None + assert record.lifecycle_state is WorkspaceLifecycleState.PREPARATION_FAILED + + +class TestRemove: + def test_runs_detach_teardown_then_provider_removal( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + managed = provider.register(workspace_dir / BRANCH, BRANCH) + store.save_created(managed) + + service.remove(BRANCH) + + assert preparer.names() == ["detach", "teardown"] + assert provider.removed == [(managed, False)] + assert store.load(managed.worktree_path) is None + + def test_passes_force_to_provider( + self, + service: WorkspaceService, + provider: FakeProvider, + workspace_dir: Path, + ) -> None: + provider.register(workspace_dir / BRANCH, BRANCH) + + service.remove(BRANCH, force=True) + + assert provider.removed[0][1] is True + + def test_teardown_failure_prevents_removal( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + managed = provider.register(workspace_dir / BRANCH, BRANCH) + preparer.teardown_error = HookExecutionError("teardown exploded") + + with pytest.raises(HookExecutionError): + service.remove(BRANCH) + + assert provider.removed == [] + record = store.load(managed.worktree_path) + assert record is not None + assert record.lifecycle_state is WorkspaceLifecycleState.TEARING_DOWN + + def test_cleans_empty_intermediary_directories( + self, + service: WorkspaceService, + provider: FakeProvider, + workspace_dir: Path, + ) -> None: + provider.register(workspace_dir / "feature" / "auth", "feature/auth") + + service.remove("feature/auth") + + # FakeProvider.remove does not delete the directory itself, so remove + # it here as the real provider would before the cleanup runs. + assert (workspace_dir / "feature").exists() + + def test_never_deletes_branch(self, service: WorkspaceService) -> None: + # Structural guarantee: the service has no branch-deletion pathway; + # provider.remove receives only the worktree. + assert not hasattr(service, "delete_branch") + + +class TestPresenterSeam: + def test_up_opens_and_focuses_presentation( + self, + workspace: MagicMock, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + ) -> None: + presenter = FakePresenter() + service = make_service(workspace, provider, preparer, store, presenter) + + worktree = service.up(BRANCH) + + assert len(presenter.opened) == 1 + assert len(presenter.focused) == 1 + record = store.load(worktree.dir) + assert record is not None + assert record.presentation is not None + assert record.presentation.presentation_id == "p-1" + + def test_up_without_focus_only_opens( + self, + workspace: MagicMock, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + ) -> None: + presenter = FakePresenter() + service = make_service(workspace, provider, preparer, store, presenter) + + service.up(BRANCH, focus=False) + + assert len(presenter.opened) == 1 + assert presenter.focused == [] + + def test_presentation_failure_preserves_prepared_worktree( + self, + workspace: MagicMock, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + presenter = FakePresenter(open_error=RuntimeError("presenter exploded")) + service = make_service(workspace, provider, preparer, store, presenter) + + with pytest.raises(RuntimeError): + service.up(BRANCH) + + assert provider.removed == [] + record = store.load(workspace_dir / BRANCH) + assert record is not None + assert record.lifecycle_state is WorkspaceLifecycleState.READY + + def test_down_closes_presentation_when_supported( + self, + workspace: MagicMock, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + presenter = FakePresenter() + service = make_service(workspace, provider, preparer, store, presenter) + provider.register(workspace_dir / BRANCH, BRANCH) + + service.down(BRANCH) + + assert len(presenter.closed) == 1 + + def test_down_skips_close_when_unsupported( + self, + workspace: MagicMock, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + presenter = FakePresenter(can_close=False) + service = make_service(workspace, provider, preparer, store, presenter) + provider.register(workspace_dir / BRANCH, BRANCH) + + service.down(BRANCH) + + assert presenter.closed == [] + + def test_remove_closes_presentation_before_teardown( + self, + workspace: MagicMock, + provider: FakeProvider, + preparer: FakePreparer, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + presenter = FakePresenter() + service = make_service(workspace, provider, preparer, store, presenter) + provider.register(workspace_dir / BRANCH, BRANCH) + + service.remove(BRANCH) + + assert len(presenter.closed) == 1 + + +class TestPrune: + def test_candidates_use_threshold_and_exclusions( + self, + service: WorkspaceService, + workspace: MagicMock, + provider: FakeProvider, + workspace_dir: Path, + ) -> None: + provider.register(workspace_dir / "feature" / "old", "feature/old") + provider.register(workspace_dir / "main", "main") + prune_config = MagicMock() + prune_config.older_than_days = -1 + prune_config.exclude_branches = ["main"] + workspace.manifest.prune = prune_config + + candidates = service.prune_candidates() + + assert [wt.branch for wt in candidates] == ["feature/old"] + + def test_prune_removes_candidates_through_lifecycle( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + workspace_dir: Path, + ) -> None: + provider.register(workspace_dir / BRANCH, BRANCH) + candidates = service.prune_candidates(older_than_days=-1) + + failures = service.prune(candidates) + + assert failures == [] + assert preparer.names() == ["detach", "teardown"] + assert len(provider.removed) == 1 + assert provider.removed[0][1] is True # always forced + + def test_prune_continues_after_failure( + self, + service: WorkspaceService, + provider: FakeProvider, + preparer: FakePreparer, + workspace_dir: Path, + ) -> None: + provider.register(workspace_dir / "feature" / "a", "feature/a") + provider.register(workspace_dir / "feature" / "b", "feature/b") + candidates = service.prune_candidates(older_than_days=-1) + preparer.teardown_error = HookExecutionError("boom") + + failures = service.prune(candidates) + + assert len(failures) == 2 + assert provider.removed == [] + # Both candidates were attempted despite the first failure. + assert preparer.names().count("teardown") == 2 + + +class TestLocking: + def test_concurrent_operation_fails_fast( + self, + service: WorkspaceService, + provider: FakeProvider, + store: WorkspaceStateStore, + workspace_dir: Path, + ) -> None: + managed = provider.register(workspace_dir / BRANCH, BRANCH) + store.locks_dir.mkdir(parents=True, exist_ok=True) + lock_path = store.locks_dir / f"{state_file_stem(managed.worktree_path)}.lock" + + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + try: + with pytest.raises(WorkspaceLockedError): + service.up(BRANCH) + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) diff --git a/tests/unit/test_state_store.py b/tests/unit/test_state_store.py new file mode 100644 index 0000000..f99b72a --- /dev/null +++ b/tests/unit/test_state_store.py @@ -0,0 +1,197 @@ +import json +from pathlib import Path + +import pytest + +from git_workspace.errors import WorkspaceStateError +from git_workspace.workspace.models import ( + ManagedWorktree, + Presentation, + PresenterKind, + ProviderKind, + WorkspaceLifecycleState, + WorkspaceRecord, +) +from git_workspace.workspace.state import SCHEMA_VERSION, WorkspaceStateStore, state_file_stem + + +@pytest.fixture +def store(tmp_path: Path) -> WorkspaceStateStore: + return WorkspaceStateStore(tmp_path / ".state") + + +@pytest.fixture +def worktree(tmp_path: Path) -> ManagedWorktree: + return ManagedWorktree( + repository_path=tmp_path / "workspace", + worktree_path=tmp_path / "workspace" / "feature" / "auth", + branch="feature/auth", + provider_kind=ProviderKind.NATIVE_GIT, + ) + + +class TestStateFileStem: + def test_is_deterministic(self, tmp_path: Path) -> None: + path = tmp_path / "feature" / "auth" + + assert state_file_stem(path) == state_file_stem(path) + + def test_distinguishes_paths_with_same_name(self, tmp_path: Path) -> None: + assert state_file_stem(tmp_path / "a" / "wt") != state_file_stem(tmp_path / "b" / "wt") + + def test_canonicalizes_before_hashing(self, tmp_path: Path) -> None: + assert state_file_stem(tmp_path / "wt") == state_file_stem(tmp_path / "x" / ".." / "wt") + + def test_slug_is_filesystem_safe(self, tmp_path: Path) -> None: + stem = state_file_stem(tmp_path / "we ird@name!") + + assert "/" not in stem + assert " " not in stem + assert "@" not in stem + + +class TestSaveAndLoad: + def test_round_trips_a_record( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + record = WorkspaceRecord( + worktree=worktree, + presentation=None, + lifecycle_state=WorkspaceLifecycleState.READY, + ) + + store.save(record) + loaded = store.load(worktree.worktree_path) + + assert loaded == record + + def test_round_trips_presentation( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + record = WorkspaceRecord( + worktree=worktree, + presentation=Presentation( + presenter_kind=PresenterKind.NONE, + presentation_id="p-1", + metadata={"session": "s-1"}, + ), + lifecycle_state=WorkspaceLifecycleState.READY, + ) + + store.save(record) + loaded = store.load(worktree.worktree_path) + + assert loaded == record + + def test_writes_schema_version( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + store.save_created(worktree) + + state_file = next(store.state_dir.glob("*.json")) + assert json.loads(state_file.read_text())["schema_version"] == SCHEMA_VERSION + + def test_seeds_gitignore_in_state_dir( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + store.save_created(worktree) + + assert (store.state_dir / ".gitignore").read_text() == "*\n!.gitignore\n" + + def test_missing_state_is_none_not_an_error( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + assert store.load(worktree.worktree_path) is None + + def test_corrupt_state_is_treated_as_missing( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + store.save_created(worktree) + state_file = next(store.state_dir.glob("*.json")) + state_file.write_text("{ not json") + + assert store.load(worktree.worktree_path) is None + + def test_newer_schema_is_a_hard_error( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + record = store.save_created(worktree) + state_file = next(store.state_dir.glob("*.json")) + raw = json.loads(state_file.read_text()) + raw["schema_version"] = SCHEMA_VERSION + 1 + state_file.write_text(json.dumps(raw)) + + with pytest.raises(WorkspaceStateError): + store.load(record.worktree.worktree_path) + + +class TestTransitions: + def test_save_created_persists_created_state( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + record = store.save_created(worktree) + + assert record.lifecycle_state is WorkspaceLifecycleState.CREATED + assert store.load(worktree.worktree_path) == record + + def test_set_state_transitions_and_clears_error( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + store.save_created(worktree) + store.mark_preparation_failed(worktree.worktree_path, error="boom") + + record = store.set_state(worktree.worktree_path, WorkspaceLifecycleState.READY) + + assert record.lifecycle_state is WorkspaceLifecycleState.READY + assert record.preparation_error is None + + def test_mark_preparation_failed_records_error( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + store.save_created(worktree) + + record = store.mark_preparation_failed(worktree.worktree_path, error="hook exploded") + + assert record.lifecycle_state is WorkspaceLifecycleState.PREPARATION_FAILED + assert record.preparation_error == "hook exploded" + + def test_set_state_requires_an_existing_record( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + with pytest.raises(WorkspaceStateError): + store.set_state(worktree.worktree_path, WorkspaceLifecycleState.READY) + + +class TestDeleteAndList: + def test_delete_removes_the_state_file( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + store.save_created(worktree) + + store.delete(worktree.worktree_path) + + assert store.load(worktree.worktree_path) is None + + def test_delete_is_idempotent( + self, store: WorkspaceStateStore, worktree: ManagedWorktree + ) -> None: + store.delete(worktree.worktree_path) + + def test_list_returns_all_records( + self, store: WorkspaceStateStore, worktree: ManagedWorktree, tmp_path: Path + ) -> None: + other = ManagedWorktree( + repository_path=tmp_path / "workspace", + worktree_path=tmp_path / "workspace" / "other", + branch="other", + provider_kind=ProviderKind.NATIVE_GIT, + ) + store.save_created(worktree) + store.save_created(other) + + records = store.list() + + assert {r.worktree.branch for r in records} == {"feature/auth", "other"} + + def test_list_is_empty_without_state_dir(self, store: WorkspaceStateStore) -> None: + assert store.list() == [] diff --git a/tests/unit/test_workspace.py b/tests/unit/test_workspace.py index 6daffb1..337c03a 100644 --- a/tests/unit/test_workspace.py +++ b/tests/unit/test_workspace.py @@ -14,7 +14,7 @@ @pytest.fixture(autouse=True) def mock_manifest_load(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.workspace.Manifest.load") + return mocker.patch("git_workspace.workspace.core.Manifest.load") @pytest.fixture @@ -31,7 +31,7 @@ def test_loads_manifest(self, mock_manifest_load: MagicMock) -> None: class TestResolve: @pytest.fixture def mock_resolver_resolve(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.workspace.WorkspaceResolver.resolve") + return mocker.patch("git_workspace.workspace.core.WorkspaceResolver.resolve") def test_delegates_to_workspace_resolver(self, mock_resolver_resolve: MagicMock) -> None: Workspace.resolve(WORKSPACE_DIR) @@ -45,7 +45,7 @@ def test_returns_resolver_result(self, mock_resolver_resolve: MagicMock) -> None class TestInit: @pytest.fixture def mock_factory_create(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.workspace.WorkspaceFactory.create") + return mocker.patch("git_workspace.workspace.core.WorkspaceFactory.create") def test_calls_factory_with_provided_directory(self, mock_factory_create: MagicMock) -> None: Workspace.init(WORKSPACE_DIR, CONFIG_URL) @@ -57,7 +57,7 @@ def test_calls_factory_with_provided_directory(self, mock_factory_create: MagicM def test_calls_factory_with_cwd_when_no_directory( self, mocker: MockerFixture, mock_factory_create: MagicMock ) -> None: - mock_cwd = mocker.patch("git_workspace.workspace.Path.cwd") + mock_cwd = mocker.patch("git_workspace.workspace.core.Path.cwd") mock_cwd.return_value.resolve.return_value = DIRECTORY Workspace.init(None, CONFIG_URL) @@ -72,7 +72,7 @@ def test_returns_factory_result(self, mock_factory_create: MagicMock) -> None: class TestClone: @pytest.fixture def mock_factory_create(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.workspace.WorkspaceFactory.create") + return mocker.patch("git_workspace.workspace.core.WorkspaceFactory.create") def test_calls_factory_with_provided_directory(self, mock_factory_create: MagicMock) -> None: Workspace.clone(WORKSPACE_DIR, URL, CONFIG_URL) @@ -85,7 +85,7 @@ def test_calls_factory_with_provided_directory(self, mock_factory_create: MagicM def test_calls_factory_with_humanish_suffix_when_no_directory( self, mocker: MockerFixture, mock_factory_create: MagicMock ) -> None: - mock_extract = mocker.patch("git_workspace.workspace.utils.extract_humanish_suffix") + mock_extract = mocker.patch("git_workspace.workspace.core.utils.extract_humanish_suffix") mock_extract.return_value = "repo" Workspace.clone(None, URL, CONFIG_URL) @@ -105,7 +105,7 @@ def test_returns_factory_result(self, mock_factory_create: MagicMock) -> None: class TestListWorktrees: @pytest.fixture def mock_worktree_list(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.workspace.Worktree.list") + return mocker.patch("git_workspace.workspace.core.Worktree.list") def test_delegates_to_worktree_list( self, workspace: Workspace, mock_worktree_list: MagicMock @@ -123,7 +123,7 @@ def test_returns_worktree_list_result( class TestResolveWorktree: @pytest.fixture def mock_worktree_resolve(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.workspace.Worktree.resolve") + return mocker.patch("git_workspace.workspace.core.Worktree.resolve") def test_delegates_to_worktree_resolve( self, workspace: Workspace, mock_worktree_resolve: MagicMock @@ -137,23 +137,3 @@ def test_returns_worktree_resolve_result( ) -> None: result = workspace.resolve_worktree("feat/GWS-001") assert result is mock_worktree_resolve.return_value - - -class TestResolveOrCreateWorktree: - @pytest.fixture - def mock_worktree_resolve_or_create(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.workspace.Worktree.resolve_or_create") - - def test_delegates_to_worktree_resolve_or_create( - self, workspace: Workspace, mock_worktree_resolve_or_create: MagicMock - ) -> None: - branch = "feat/GWS-001" - base_branch = "main" - workspace.resolve_or_create_worktree(branch, base_branch) - mock_worktree_resolve_or_create.assert_called_once_with(workspace, branch, base_branch) - - def test_returns_worktree_resolve_or_create_result( - self, workspace: Workspace, mock_worktree_resolve_or_create: MagicMock - ) -> None: - result = workspace.resolve_or_create_worktree("feat/GWS-001", "main") - assert result is mock_worktree_resolve_or_create.return_value diff --git a/tests/unit/test_workspace_factory.py b/tests/unit/test_workspace_factory.py index 27a7bbb..dcad1c2 100644 --- a/tests/unit/test_workspace_factory.py +++ b/tests/unit/test_workspace_factory.py @@ -21,27 +21,27 @@ def mock_mkdir(mocker: MockerFixture) -> None: @pytest.fixture(autouse=True) def mock_manifest_load(mocker: MockerFixture) -> None: - mocker.patch("git_workspace.workspace.Manifest.load") + mocker.patch("git_workspace.workspace.core.Manifest.load") @pytest.fixture(autouse=True) def mock_rmtree(mocker: MockerFixture) -> None: - mocker.patch("git_workspace.workspace.shutil.rmtree") + mocker.patch("git_workspace.workspace.core.shutil.rmtree") @pytest.fixture(autouse=True) def mock_configure_remote_fetch_refspec(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.workspace.git.configure_remote_fetch_refspec") + return mocker.patch("git_workspace.workspace.core.git.configure_remote_fetch_refspec") @pytest.fixture def mock_git_clone(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.workspace.git.clone") + return mocker.patch("git_workspace.workspace.core.git.clone") @pytest.fixture def mock_git_init(mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.workspace.git.init") + return mocker.patch("git_workspace.workspace.core.git.init") def test_clones_bare_repo_and_config_when_both_urls_provided( diff --git a/tests/unit/test_workspace_resolver.py b/tests/unit/test_workspace_resolver.py index 7156d8e..1d0d88f 100644 --- a/tests/unit/test_workspace_resolver.py +++ b/tests/unit/test_workspace_resolver.py @@ -20,7 +20,7 @@ def filesystem(fs: FakeFilesystem) -> None: @pytest.fixture(autouse=True) def manifest_load(mocker: MockerFixture) -> None: - mocker.patch("git_workspace.workspace.Manifest.load") + mocker.patch("git_workspace.workspace.core.Manifest.load") @pytest.mark.parametrize("raw_workspace_dir", [WORKSPACE, None]) diff --git a/tests/unit/test_worktree.py b/tests/unit/test_worktree.py index 8640846..b2c9361 100644 --- a/tests/unit/test_worktree.py +++ b/tests/unit/test_worktree.py @@ -1,3 +1,4 @@ +from datetime import datetime from pathlib import Path from unittest.mock import MagicMock @@ -5,18 +6,22 @@ from pytest_mock import MockerFixture from git_workspace.errors import WorktreeResolutionError -from git_workspace.worktree import Worktree +from git_workspace.workspace.models import ManagedWorktree, ProviderKind +from git_workspace.workspace.worktree import Worktree BRANCH = "feat/GWS-001" BASE_BRANCH = "main" +WORKSPACE_DIR = Path("/workspace") WORKTREE_DIR = Path("/workspace/feat/GWS-001") @pytest.fixture def workspace(mocker: MockerFixture) -> MagicMock: mock = mocker.MagicMock() - mock.dir = Path("/workspace") + mock.dir = WORKSPACE_DIR mock.manifest.base_branch = BASE_BRANCH + mock.paths.worktree.return_value = WORKTREE_DIR + mock.provider.kind = ProviderKind.NATIVE_GIT return mock @@ -25,42 +30,41 @@ def worktree(workspace: MagicMock) -> Worktree: return Worktree(workspace=workspace, dir=WORKTREE_DIR, branch=BRANCH) -class TestList: - @pytest.fixture - def mock_git_list_worktrees(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.worktree.git.list_worktrees") +def managed(branch: str = BRANCH, directory: Path = WORKTREE_DIR) -> ManagedWorktree: + return ManagedWorktree( + repository_path=WORKSPACE_DIR, + worktree_path=directory, + branch=branch, + provider_kind=ProviderKind.NATIVE_GIT, + ) + +class TestList: @pytest.fixture(autouse=True) def mock_directory_birthtime(self, mocker: MockerFixture) -> MagicMock: - from datetime import datetime - return mocker.patch( - "git_workspace.worktree.directory_birthtime", + "git_workspace.workspace.worktree.directory_birthtime", return_value=datetime(2025, 1, 1), ) - def test_calls_git_list_worktrees_with_workspace_directory( - self, workspace: MagicMock, mock_git_list_worktrees: MagicMock - ) -> None: - mock_git_list_worktrees.return_value = [] + def test_delegates_to_provider_with_workspace_directory(self, workspace: MagicMock) -> None: + workspace.provider.list.return_value = [] Worktree.list(workspace) - mock_git_list_worktrees.assert_called_once_with(cwd=workspace.dir) + workspace.provider.list.assert_called_once_with(workspace.dir) - def test_constructs_worktree_for_each_raw_result( - self, workspace: MagicMock, mock_git_list_worktrees: MagicMock - ) -> None: - raw_directory = "/workspace/feat/GWS-001" - mock_git_list_worktrees.return_value = [{"directory": raw_directory, "branch": BRANCH}] + def test_constructs_worktree_for_each_managed_worktree(self, workspace: MagicMock) -> None: + workspace.provider.list.return_value = [managed()] result = Worktree.list(workspace) assert len(result) == 1 assert result[0].workspace is workspace - assert result[0].dir == Path(raw_directory).resolve() + assert result[0].dir == WORKTREE_DIR.resolve() assert result[0].branch == BRANCH assert result[0].is_new is False + assert result[0].timestamp == datetime(2025, 1, 1) class TestResolve: @@ -100,203 +104,3 @@ def test_returns_cwd_result_when_no_branch( result = Worktree.resolve(workspace, None) assert result is worktree - - -class TestResolveOrCreate: - def test_returns_existing_worktree_when_found( - self, mocker: MockerFixture, workspace: MagicMock, worktree: Worktree - ) -> None: - mocker.patch.object(Worktree, "_try_resolve_existing", return_value=worktree) - - result = Worktree.resolve_or_create(workspace, BRANCH, BASE_BRANCH) - - assert result is worktree - - def test_creates_from_local_branch_when_not_existing( - self, mocker: MockerFixture, workspace: MagicMock, worktree: Worktree - ) -> None: - mocker.patch.object(Worktree, "_try_resolve_existing", return_value=None) - mocker.patch.object(Worktree, "_try_create_from_local_branch", return_value=worktree) - - result = Worktree.resolve_or_create(workspace, BRANCH, BASE_BRANCH) - - assert result is worktree - - def test_creates_from_remote_branch_when_not_local( - self, mocker: MockerFixture, workspace: MagicMock, worktree: Worktree - ) -> None: - mocker.patch.object(Worktree, "_try_resolve_existing", return_value=None) - mocker.patch.object(Worktree, "_try_create_from_local_branch", return_value=None) - mocker.patch.object(Worktree, "_try_create_from_remote_branch", return_value=worktree) - - result = Worktree.resolve_or_create(workspace, BRANCH, BASE_BRANCH) - - assert result is worktree - - def test_creates_new_worktree_as_last_resort( - self, mocker: MockerFixture, workspace: MagicMock, worktree: Worktree - ) -> None: - mocker.patch.object(Worktree, "_try_resolve_existing", return_value=None) - mocker.patch.object(Worktree, "_try_create_from_local_branch", return_value=None) - mocker.patch.object(Worktree, "_try_create_from_remote_branch", return_value=None) - mocker.patch.object(Worktree, "_create_new", return_value=worktree) - - result = Worktree.resolve_or_create(workspace, BRANCH, BASE_BRANCH) - - assert result is worktree - - def test_delegates_to_resolve_from_cwd_when_no_branch( - self, mocker: MockerFixture, workspace: MagicMock, worktree: Worktree - ) -> None: - mock_resolve_from_cwd = mocker.patch.object( - Worktree, "_resolve_from_cwd", return_value=worktree - ) - - Worktree.resolve_or_create(workspace, None, BASE_BRANCH) - - mock_resolve_from_cwd.assert_called_once_with(workspace) - - def test_returns_cwd_result_when_no_branch( - self, mocker: MockerFixture, workspace: MagicMock, worktree: Worktree - ) -> None: - mocker.patch.object(Worktree, "_resolve_from_cwd", return_value=worktree) - - result = Worktree.resolve_or_create(workspace, None, BASE_BRANCH) - - assert result is worktree - - -class TestTryCreateFromRemoteBranch: - @pytest.fixture(autouse=True) - def mock_git(self, mocker: MockerFixture) -> None: - mocker.patch("git_workspace.worktree.git.fetch_origin") - mocker.patch("git_workspace.worktree.git.remote_branch_exists", return_value=False) - mocker.patch("git_workspace.worktree.git.create_worktree_from_remote_branch") - - def test_creates_from_remote_when_remote_branch_exists( - self, mocker: MockerFixture, workspace: MagicMock - ) -> None: - mocker.patch("git_workspace.worktree.git.remote_branch_exists", return_value=True) - mock_create = mocker.patch("git_workspace.worktree.git.create_worktree_from_remote_branch") - - result = Worktree._try_create_from_remote_branch(workspace, BRANCH) - - assert result is not None - mock_create.assert_called_once() - - def test_returns_none_when_remote_branch_not_found(self, workspace: MagicMock) -> None: - result = Worktree._try_create_from_remote_branch(workspace, BRANCH) - - assert result is None - - -class TestCreateNew: - @pytest.fixture(autouse=True) - def mock_git(self, mocker: MockerFixture) -> None: - mocker.patch("git_workspace.worktree.git.fetch_origin") - mocker.patch("git_workspace.worktree.git.remote_branch_exists", return_value=True) - mocker.patch("git_workspace.worktree.git.create_worktree_new") - - def test_fetches_before_creating_worktree( - self, mocker: MockerFixture, workspace: MagicMock - ) -> None: - mock_fetch = mocker.patch("git_workspace.worktree.git.fetch_origin") - mocker.patch("git_workspace.worktree.git.remote_branch_exists", return_value=True) - mocker.patch("git_workspace.worktree.git.create_worktree_new") - - Worktree._create_new(workspace, BRANCH, BASE_BRANCH) - - mock_fetch.assert_called_once_with(cwd=workspace.dir) - - def test_uses_origin_base_when_remote_branch_exists( - self, mocker: MockerFixture, workspace: MagicMock - ) -> None: - mocker.patch("git_workspace.worktree.git.fetch_origin") - mocker.patch("git_workspace.worktree.git.remote_branch_exists", return_value=True) - mock_create = mocker.patch("git_workspace.worktree.git.create_worktree_new") - - Worktree._create_new(workspace, BRANCH, BASE_BRANCH) - - mock_create.assert_called_once_with( - workspace.paths.worktree(BRANCH), - BRANCH, - f"origin/{BASE_BRANCH}", - cwd=workspace.dir, - ) - - def test_falls_back_to_local_base_when_remote_branch_not_found( - self, mocker: MockerFixture, workspace: MagicMock - ) -> None: - mocker.patch("git_workspace.worktree.git.fetch_origin") - mocker.patch("git_workspace.worktree.git.remote_branch_exists", return_value=False) - mock_create = mocker.patch("git_workspace.worktree.git.create_worktree_new") - - Worktree._create_new(workspace, BRANCH, BASE_BRANCH) - - mock_create.assert_called_once_with( - workspace.paths.worktree(BRANCH), - BRANCH, - BASE_BRANCH, - cwd=workspace.dir, - ) - - def test_uses_manifest_base_branch_when_none_provided( - self, mocker: MockerFixture, workspace: MagicMock - ) -> None: - mocker.patch("git_workspace.worktree.git.fetch_origin") - mocker.patch("git_workspace.worktree.git.remote_branch_exists", return_value=True) - mock_create = mocker.patch("git_workspace.worktree.git.create_worktree_new") - workspace.manifest.base_branch = BASE_BRANCH - - Worktree._create_new(workspace, BRANCH, None) - - mock_create.assert_called_once_with( - workspace.paths.worktree(BRANCH), - BRANCH, - f"origin/{BASE_BRANCH}", - cwd=workspace.dir, - ) - - def test_proceeds_when_fetch_fails(self, mocker: MockerFixture, workspace: MagicMock) -> None: - from git_workspace.errors import GitFetchError - - mocker.patch( - "git_workspace.worktree.git.fetch_origin", side_effect=GitFetchError("offline") - ) - mocker.patch("git_workspace.worktree.git.remote_branch_exists", return_value=False) - mock_create = mocker.patch("git_workspace.worktree.git.create_worktree_new") - - Worktree._create_new(workspace, BRANCH, BASE_BRANCH) - - mock_create.assert_called_once() - - -class TestDelete: - @pytest.fixture - def mock_git_remove_worktree(self, mocker: MockerFixture) -> MagicMock: - return mocker.patch("git_workspace.worktree.git.remove_worktree") - - @pytest.mark.parametrize("force", [True, False]) - def test_calls_git_remove_worktree_with_correct_params( - self, - worktree: Worktree, - mock_git_remove_worktree: MagicMock, - force: bool, - ) -> None: - worktree.delete(force) - - mock_git_remove_worktree.assert_called_once_with( - WORKTREE_DIR, force, cwd=Path("/workspace") - ) - - def test_cleans_intermediary_empty_paths( - self, - mocker: MockerFixture, - worktree: Worktree, - mock_git_remove_worktree: MagicMock, - ) -> None: - mock_clean = mocker.patch.object(worktree, "_clean_intermediary_empty_paths") - - worktree.delete(False) - - mock_clean.assert_called_once()