Metadata-Version: 2.4
Name: git-workspace-cli
Version: 0.8.0b1
Summary: An opinionated git plugin that wraps git worktrees with a lifecycle system
Keywords: git,worktree,workspace,cli,developer-tools
Author: Gabriel Lima
Author-email: Gabriel Lima <gvclima@gmail.com>
License-Expression: MIT
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Topic :: Software Development :: Version Control :: Git
Requires-Dist: jinja2>=3.1
Requires-Dist: tomlkit>=0.13
Requires-Dist: typer>=0.24.1
Requires-Python: >=3.14
Project-URL: Homepage, https://github.com/ewilazarus/git-workspace
Project-URL: Repository, https://github.com/ewilazarus/git-workspace
Project-URL: Issues, https://github.com/ewilazarus/git-workspace/issues
Project-URL: Changelog, https://github.com/ewilazarus/git-workspace/blob/main/CHANGELOG.md
Description-Content-Type: text/markdown

<p align="center">
  <img src=".github/banner.png" width="800" />
</p>

<h3 align="center"><code>git-workspace</code></h3>
<h6 align="center">Local environments with zero friction.</h6>

<br/>

<p align="center">
  <a href="https://github.com/ewilazarus/git-workspace/actions/workflows/ci.yml"><img src="https://github.com/ewilazarus/git-workspace/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
  &nbsp;
  <a href="https://github.com/ewilazarus/git-workspace/actions/workflows/pypi.yml"><img src="https://github.com/ewilazarus/git-workspace/actions/workflows/pypi.yml/badge.svg" alt="PyPI"></a>
</p>

<hr/>

`git-workspace` is an opinionated git plugin that wraps [git worktrees](https://git-scm.com/docs/git-worktree) with a lifecycle system — so switching between branches feels like switching between projects, not shuffling stashes.

#### The problem it solves

`git stash`, `git switch`, re-run your dev server, restore your editor tabs. Repeat twenty times a day.

With `git-workspace`, each branch lives in its own directory. You `up` into it, your environment is ready — dependencies installed, config files in place, hooks executed. You `down` out of it, your teardown scripts run. You come back tomorrow and everything is exactly where you left it.

<details>
<summary><i>Table of contents</i></summary>
<br/>

- [Features](#features)
- [Demo](#demo)
- [How it works](#how-it-works)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Commands](#commands)
- [Workspace manifest](#workspace-manifest)
- [Lifecycle hooks](#lifecycle-hooks)
- [Fingerprints](#fingerprints)
- [Assets: links and copies](#assets-links-and-copies)
- [Pruning stale worktrees](#pruning-stale-worktrees)
- [Detached mode](#detached-mode)
- [Diagnosing a workspace](#diagnosing-a-workspace)
- [Debugging](#debugging)
- [Development](#development)
</details>

---

## Features

- 🌳 **Worktree-per-branch** — every branch gets its own directory; no more dirty working trees
- 🪝 **Lifecycle hooks** — run scripts on setup, attach, detach, and teardown
- 🔗 **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
- 📦 **Variables** — pass manifest-level and runtime variables into hooks as environment variables
- 🔏 **Fingerprints** — hash worktree files and expose the digest for further processing
- 🧭 **CWD-aware** — detects when you're already inside a workspace or worktree
- 🏗️ **Detached mode** — skip interactive hooks for headless, CI, or agent workflows
- 🧹 **Stale worktree pruning** — clean up old worktrees by age with dry-run preview
- 🩺 **Workspace diagnostics** — detect manifest errors, missing assets, broken hook references, and more
- 🎨 **Rich terminal UI** — styled output, progress bars, and sortable worktree tables
- 🗂️ **Config as code** — workspace configuration lives in its own git repo, versioned and shareable

---

## Demo

<img src=".github/demo/demo.gif" width="800" />

---

## How it works

A `git-workspace` workspace is a directory containing:

```
my-project/
├── .git/           ← bare git clone of your repository
├── .workspace/     ← clone of your config repository
│   ├── manifest.toml
│   ├── assets/     ← files to be linked or copied into worktrees
│   └── bin/        ← lifecycle hook scripts
├── main/           ← worktree for the main branch
├── feature/
│   └── my-feature/ ← worktree for feature/my-feature
└── ...
```

Each subdirectory is a fully functional git worktree. You work inside them like normal repositories.

---

## Installation

Requires Python 3.14+.

**With `uv` (recommended):**

```bash
uv tool install git-workspace-cli
```

**With `pip`:**

```bash
pip install git-workspace-cli
```

Once installed, `git workspace` is available as a git subcommand.

<br/>

> [!WARNING]
> Ensure your `uv`/`pip` install path is in `$PATH`, so Git can locate the `git-workspace` executable.

---

## Quick start

**Start from an existing repository:**

```bash
git workspace clone https://github.com/you/your-repo.git
cd your-repo
cd $(git workspace up hotfix/urgent -o)
```

**Start a brand new project:**

```bash
mkdir my-project && cd my-project
git workspace init
cd $(git workspace up main -o)
```

You're now inside `my-project/main/` — a real git worktree on the `main` branch.

---

## Commands

> [!TIP]
> Use `git workspace --help` to explore all commands and flags in detail.

<br/>

| Command | Description |
|---|---|
| `git workspace init` | Initialize a new workspace in the current directory |
| `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 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) |
| `git workspace doctor` | Inspect the workspace for inconsistencies; `--fix` applies safe remediations |
| `git workspace edit` | Open the workspace config in your editor |
| `git workspace compose` | Run docker compose against the workspace's shared compose file |

`[branch]` and `--root` let you operate on a workspace from anywhere in the file system, without needing to be inside it.

---

## Workspace manifest

The manifest lives at `.workspace/manifest.toml` and controls everything. For example:

```toml
version = 1
base_branch = "main"

# Variables injected into every hook as GIT_WORKSPACE_VAR_*
[vars]
node-version = "22"
registry     = "https://registry.npmjs.org"

# Lifecycle hooks (.workspace/bin/ scripts and inline commands)
[[hooks.on_setup]]
commands = ["install_deps", "docker build . -t myproj:latest"]

[[hooks.on_attach]]
commands = ["open_editor"]

[[hooks.on_detach]]
commands = ["save_state"]

[[hooks.on_teardown]]
commands = ["clean_cache"]

# Symlinks applied to every worktree
[[link]]
source = "vscode-settings.json"
target = ".vscode/settings.json"

# File copies — each worktree gets its own mutable version
[[copy]]
source = "config.local.yaml"
target = "config.local.yaml"
```

---

## Lifecycle hooks

Each hook entry can be a script in `.workspace/bin/` or an inline shell command. If the entry matches a file in `.workspace/bin/`, it runs as a script; otherwise it's executed via the user shell. 

### Hook execution order

Hooks come in two pairs that map to the two lifetimes a worktree has:

- **Worktree lifetime** — `on_setup` and `on_teardown` bracket the full existence of the worktree directory.
- **Coding Session lifetime** — `on_attach` and `on_detach` bracket each interactive session inside it.

  | Event | When it runs |
  |---|---|
  | `on_setup` | After a worktree is first created, or on `reset` |
  | `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 |

<br/>

> [!WARNING]
> Implement `on_attach` and `on_detach` hooks as idempotent operations. `git-workspace` treats these lifecycle events as potentially repeatable rather than strictly linear, accounting for edge cases where a session is interrupted before completion.

<br/>

<details>
<summary><i>Environment variables</i></summary>
<br/>

The following environment variables are available during hook execution:

| Variable | Value |
|---|---|
| `GIT_WORKSPACE_ROOT` | Absolute path to the workspace root |
| `GIT_WORKSPACE_NAME` | Workspace root directory name |
| `GIT_WORKSPACE_WORKTREE` | Absolute path to the current worktree |
| `GIT_WORKSPACE_BRANCH` | Current branch name |
| `GIT_WORKSPACE_EVENT` | The lifecycle event that triggered the hook |
| `GIT_WORKSPACE_VAR_*` | All manifest and runtime variables |
| `GIT_WORKSPACE_FINGERPRINT_*` | Content hashes computed from `[[fingerprint]]` file sets |

</details>

<details>
<summary><i>Conditional execution</i></summary>
<br/>

Each hook event can have multiple `[[hooks.<event>]]` groups. A group only runs when its `conditions` block matches the effective branch. Groups with no `conditions` always run. Groups are evaluated top-to-bottom in manifest order.

#### Supported conditions

| Key | Behaviour |
|---|---|
| `if_branch_matches` | Run only when the branch matches the glob pattern |
| `if_branch_not_matches` | Run only when the branch does **not** match the glob pattern |

Both conditions use POSIX glob syntax (`*`, `?`, `[...]`). When both keys are present they are AND-ed: the group runs only when both hold.

**Example:**

```toml
# Always runs — no conditions
[[hooks.on_setup]]
commands = ["npm install"]

# Only on your own branches
[[hooks.on_setup]]
conditions = { if_branch_matches = "gabriel/*" }
commands = ["tmux attach -t MYSESSION"]

# Only on other branches
[[hooks.on_setup]]
conditions = { if_branch_not_matches = "gabriel/*" }
commands = ["echo not my branch"]

# Only on gabriel/* but not wip branches (AND)
[[hooks.on_setup]]
conditions = { if_branch_matches = "gabriel/*", if_branch_not_matches = "gabriel/wip-*" }
commands = ["start_long_running_task"]
```

#### Impersonating a branch with `--as`

All hook-running commands (`up`, `down`, `reset`, `rm`) accept `-a`/`--as <branch>` 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
git workspace up feat/my-feature --as gabriel/my-feature
```

This is useful when a shared feature branch should trigger the same hooks as a personal branch, or when scripting against a branch that doesn't exist yet.

</details>

---

## Fingerprints

Fingerprints let you compute a short content hash over a set of files in the worktree and expose it as an environment variable. This gives hooks a cheap way to detect whether their inputs have changed — for example, only re-run `npm install` when `package-lock.json` changes, or only rebuild a Docker image when the Dockerfile or dependency files change.

Declare fingerprints as `[[fingerprint]]` blocks in the manifest:

```toml
[[fingerprint]]
name = "docker-deps"
files = [
    "Dockerfile",
    "package.json",
    "package-lock.json",
]
algorithm = "sha256"  # optional; default: sha256
length = 12           # optional; default: 12
```

Each fingerprint is exposed as `GIT_WORKSPACE_FINGERPRINT_<NORMALIZED_NAME>` (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.

<details>
<summary><i>Usage example</i></summary>
<br/>

```sh
#!/bin/sh

state_file="$GIT_WORKSPACE_ROOT/.fingerprint-docker-deps"
current="$GIT_WORKSPACE_FINGERPRINT_DOCKER_DEPS"
previous=$(cat "$state_file" 2>/dev/null || echo "")

if [ "$current" != "$previous" ]; then
    docker build . -t myapp
    echo "$current" > "$state_file"
fi
```

</details>

---

## 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`.

### Links

Symbolic links from `.workspace/assets` into the worktree. The source asset is shared across all worktrees — editing the link edits the original.

```toml
[[link]]
source = "env.local"
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.

```toml
[[copy]]
source = "config.local.yaml"
target = "config.local.yaml"
```

<details>
<summary><i>Overwrite control</i></summary>
<br/>

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.

```toml
[[copy]]
source = "config.local.yaml"
target = "config.local.yaml"
overwrite = false
```

</details>

<details>
<summary><i>Templating</i></summary>
<br/>

Files in `.workspace/assets/` whose name ends in `.j2` are rendered as [Jinja2](https://jinja.palletsprojects.com/) templates. Files without `.j2` are copied verbatim. The target path is whatever you write in the manifest — the `.j2` suffix is **not** stripped automatically, so use an explicit `target` if you want a different output name:

```toml
[[copy]]
source = "config.local.yaml.j2"
target = "config.local.yaml"
```

Every `GIT_WORKSPACE_*` variable available to hooks — base, manifest, runtime, and fingerprint vars — is exposed by name inside the template:

```
# .workspace/assets/config.local.yaml.j2
branch: {{ GIT_WORKSPACE_BRANCH }}
worktree: {{ GIT_WORKSPACE_WORKTREE }}
env: {{ GIT_WORKSPACE_VAR_ENV }}
```

After `git workspace up feature/my-feature -v env=staging`, the copied file becomes:

```yaml
branch: feature/my-feature
worktree: /path/to/workspace/feature/my-feature
env: staging
```

Beyond simple substitution, you also get conditionals, loops, and filters — useful when a single asset needs to differ across branches:

```
{% if GIT_WORKSPACE_BRANCH == "main" %}
log_level: warn
{% else %}
log_level: debug
{% endif %}
```

Unknown variables are left verbatim (e.g. `{{ GIT_WORKSPACE_TYPO }}` is written as-is so typos are obvious). Only `GIT_WORKSPACE_*` keys are exposed — the host process environment is not. Plain (non-`.j2`) files — text or binary — are copied byte-for-byte. In a directory copy, each file is rendered or copied based on its own suffix; on-disk filenames are preserved in the output. `git workspace doctor` flags unknown variables and template syntax errors in `.j2` files.

</details>

### Override mode

By default, asset targets are added to `.git/info/exclude` so they stay invisible to git. Set `override = true` to replace a tracked file instead — the target is marked with `git update-index --skip-worktree` before the asset is applied.

```toml
[[link]]  # or [[copy]]
source = "vscode-settings.json"
target = ".vscode/settings.json"
override = true
```

---

## Pruning stale worktrees

Over time, worktrees accumulate. The `prune` command removes the ones you're no longer using:

```bash
# preview what would be removed (default)
git workspace prune --older-than-days 14

# actually remove them
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`:

```toml
[prune]
older_than_days  = 30
exclude_branches = ["main", "develop"]
```

---

## Shared services via docker compose

Drop a compose file under `.workspace/` to define services that are shared across all worktrees — databases, caches, message queues, anything that doesn't need a branch-specific checkout:

```bash
# .workspace/compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
  redis:
    image: redis:7
```

Then use `git workspace compose` to manage them from anywhere inside (or outside via `-r`) the workspace:

```bash
# start services in the background
git workspace compose up -d

# tail logs
git workspace compose logs -f

# stop everything
git workspace compose down

# from outside the workspace
git workspace compose -r ~/projects/my-workspace ps
```

The project name is automatically derived from the workspace directory name (slugified to match docker compose naming rules), so services from different workspaces never collide.

Supported compose file names (in precedence order): `compose.yaml`, `compose.yml`, `docker-compose.yaml`, `docker-compose.yml`.

---

## Detached mode

For CI pipelines, automation, or agent workflows where you don't want interactive hooks to fire:

```bash
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:

```bash
WORKTREE=$(git workspace up main --detached -o)
```

---

## Diagnosing a workspace

`git workspace doctor` inspects the workspace configuration and reports anything that would cause commands to fail or behave unexpectedly.

```bash
git workspace doctor
```

If everything is in order:

```
✓  Workspace is healthy.
```

Otherwise it lists findings by severity:

```
✗  Link source 'dotfile' does not exist in assets/
⚠  Script 'bin/old_script.sh' is not referenced by any hook
⚠  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).

The command exits 1 if any errors are found, 0 if the workspace is clean or has warnings only.

<details>
<summary><i>Automatic fixes</i></summary>
<br/>

Pass `--fix` to apply remediations alongside the findings:

```bash
git workspace doctor --fix
```

Safe fixes are applied silently. Destructive fixes (deleting files) prompt for confirmation first:

```
⚠  Hook script 'bin/on_setup' exists but is not executable
   → Fixed: Make bin/on_setup executable
⚠  Asset 'old_config.json' is not referenced by any link or copy
   → Delete unreferenced asset old_config.json? [y/N] y
   → Fixed: Delete unreferenced asset old_config.json
⚠  base_branch 'develop' does not resolve to any local or remote ref
✓  1 fixed, 0 skipped → 1 remaining
```

Pass `--yes` (or `-y`) to skip all prompts — useful in CI:

```bash
git workspace doctor --fix --yes
```

<br/>

**NOTE:** Not all problems can be fixed automatically. Issues requiring manual judgment (e.g. clashing asset targets, unsupported manifest version, missing asset sources) are reported but left untouched.
</details>

<details>
<summary><i>Checks</i></summary>
<br/>

| Level | Check | Description | `--fix` |
|---|---|---|---|
| error | Manifest not readable / invalid TOML | The manifest file cannot be opened or parsed | — |
| error | Unsupported manifest version | `version` is higher than this tool supports | — |
| error | Missing asset source | A `[[link]]` or `[[copy]]` source file does not exist in `assets/` | prompt |
| error | Clashing asset targets | Two entries share the same `target` path | — |
| error | Escaping asset target | A `target` path traverses outside the worktree root (e.g. `../../`) | — |
| error | Variable name collision | Two `[vars]` keys normalize to the same `GIT_WORKSPACE_VAR_*` name | — |
| error | Fingerprint name collision | Two `[[fingerprint]]` names normalize to the same `GIT_WORKSPACE_FINGERPRINT_*` name | — |
| error | Empty fingerprint name | A `[[fingerprint]]` has an empty or whitespace-only `name` | — |
| error | Escaping fingerprint file | A `files` entry traverses outside the worktree root (e.g. `../../`) | — |
| error | Unsupported fingerprint algorithm | `algorithm` is not `sha256` or `md5` | — |
| error | Invalid fingerprint length | `length` is zero or negative | — |
| warning | Missing bin script | A whitespace-free hook entry has no matching file in `bin/` | prompt |
| warning | Non-executable bin script | A matching `bin/` file exists but is not executable | auto |
| warning | Empty hook entry | A hook list contains an empty or whitespace-only string | auto |
| warning | Duplicate hook entry | The same entry appears more than once in the same hook event | auto |
| warning | Empty hook group | A `[[hooks.X]]` block has no commands | auto |
| warning | Orphaned bin script | A file in `bin/` is not referenced by any hook | prompt |
| warning | Orphaned asset | A file in `assets/` is not referenced by any `[[link]]` or `[[copy]]` | prompt |
| 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 | 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 |
| warning | Fingerprint length exceeds digest | `length` is larger than the algorithm's full digest size; the full digest is used | — |

`auto` = applied silently; `prompt` = asks for confirmation (`--yes` skips the prompt); `—` = no automatic fix available.

</details>

---

## Debugging

Set `GIT_WORKSPACE_LOG_LEVEL` to get diagnostic output on stderr:

```bash
GIT_WORKSPACE_LOG_LEVEL=DEBUG git workspace up main
```

Supported levels: `DEBUG`, `INFO`, `WARNING`, `ERROR`. Logging is silent by default.

---

## Development

**Clone and set up:**

```bash
git clone https://github.com/ewilazarus/git-workspace.git
cd git-workspace
uv sync
uv run pre-commit install
```

**Run the tests:**

```bash
uv run pytest
```

The test suite includes both unit tests and integration tests. Integration tests spin up real git repositories in temporary directories — no mocking.

<details>
<summary><i>Lint and type check</i></summary>
<br/>

```bash
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
uv run ty check src/
```

</details>

<details>
<summary><i>Project layout</i></summary>
<br/>

```
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
```

</details>

---

## Disclaimer

I built `git-workspace` because it fits *my* way of working. The worktree-per-branch model, the hook lifecycle, the asset injection — these are the exact primitives I was missing.

If it turns out to be useful to you too, [consider supporting the project](https://buymeacoffee.com/simiosoft). Contributions and feedback are welcome!

<br/>

> [!NOTE]
> Developed and verified on macOS. Linux support is expected but untested. Windows is not supported.

---

*Built with [Typer](https://typer.tiangolo.com), [Rich](https://rich.readthedocs.io), [Jinja2](https://jinja.palletsprojects.com), and a deep appreciation for git worktrees.*
