Skip to main content

An opinionated git plugin that wraps git worktrees with a lifecycle system

Project description

git-workspace

Local environments with zero friction.

CI ย  PyPI


git-workspace is an opinionated git plugin that wraps git worktrees 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.

Table of contents


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

https://github.com/user-attachments/assets/27bf0e6e-ac8c-424e-b899-8601ac4d54b7


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):

uv tool install git-workspace-cli

With pip:

pip install git-workspace-cli

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

[!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:

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:

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.

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
git workspace edit Open the workspace config in your editor

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

Path output for automation

init, clone, and up accept an -o / --output flag that prints the resulting path to stdout and suppresses all other output. This makes them composable with shell subexpressions:

# jump straight into a new worktree in one command
cd $(git workspace up feat/my-feature -o)

# clone a repo and land inside it immediately
cd $(git workspace clone https://github.com/you/your-repo.git -o)

# use in scripts without worrying about hook output polluting the result
WORKTREE=$(git workspace up feat/experiment --detached -o)
code "$WORKTREE"

Workspace manifest

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

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 = "dotfile"
target = ".nvmrc"

[[link]]
source = "vscode-settings.json"
target = ".vscode/settings.json"
override = true

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

# Automatic cleanup rules
[prune]
older_than_days  = 30
exclude_branches = ["main", "develop"]

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 sh -c. Both forms receive the following environment variables:

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

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.
  • 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

Example hook (.workspace/bin/install_deps):

#!/bin/sh
# hooks already run from the worktree root โ€” no cd needed
node_version="$GIT_WORKSPACE_VAR_NODE_VERSION"
fnm use "$node_version" || fnm install "$node_version"
npm install

Mix bin scripts and inline commands in the same hook list:

[[hooks.on_setup]]
commands = ["install_deps", "docker build . -t myproj:latest", "echo ready"]

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

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

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

Here install_deps runs .workspace/bin/install_deps, while docker build . -t myproj:latest and echo ready run as shell commands.

Pass runtime variables at call time with -v:

git workspace up feature/my-feature -v env=staging -v debug=true

Adaptive hooks

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:

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

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


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:

[[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.

Example hook (.workspace/bin/install_deps):

#!/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

Properties:

Property Required Default Description
name yes โ€” Identifier; normalized to GIT_WORKSPACE_FINGERPRINT_<NAME>
files yes โ€” Paths relative to the worktree root; sorted alphabetically before hashing
algorithm no sha256 Hash algorithm: sha256 or md5
length no 12 Prefix length of the hex digest to expose

Fingerprint placeholders ({{ GIT_WORKSPACE_FINGERPRINT_* }}) are also recognized in copy assets (see Placeholders in copies).


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.

[[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.

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

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.

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

Placeholders in copies

Text files in .workspace/assets/ can contain {{ GIT_WORKSPACE_* }} placeholders. When the file is copied, each placeholder is replaced with the corresponding value from the environment โ€” the same variables available to hooks, including manifest and runtime vars.

# .workspace/assets/config.local.yaml
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:

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

Unknown placeholders are left verbatim. Binary files are copied as-is without substitution. When the source is a directory, substitution is applied to every text file inside it.

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.

[[link]]
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:

# 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:

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

Detached mode

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

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:

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.

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.

What it checks

Errors:

Check Description
Manifest not readable / invalid TOML The manifest file cannot be opened or parsed
Unsupported manifest version version is higher than this tool supports
Missing asset source A [[link]] or [[copy]] source file does not exist in assets/
Clashing asset targets Two entries share the same target path
Escaping asset target A target path traverses outside the worktree root (e.g. ../../)
Variable name collision Two [vars] keys normalize to the same GIT_WORKSPACE_VAR_* name
Fingerprint name collision Two [[fingerprint]] names normalize to the same GIT_WORKSPACE_FINGERPRINT_* name
Empty fingerprint name A [[fingerprint]] has an empty or whitespace-only name
Escaping fingerprint file A files entry traverses outside the worktree root (e.g. ../../)
Unsupported fingerprint algorithm algorithm is not sha256 or md5
Invalid fingerprint length length is zero or negative

Warnings:

Check Description
Missing bin script A whitespace-free hook entry has no matching file in bin/
Non-executable bin script A matching bin/ file exists but is not executable
Empty hook entry A hook list contains an empty or whitespace-only string
Duplicate hook entry The same entry appears more than once in the same hook event
Orphaned bin script A file in bin/ is not referenced by any hook
Orphaned asset A file in assets/ is not referenced by any [[link]] or [[copy]]
Unknown copy placeholder A {{ GIT_WORKSPACE_* }} placeholder in a copy asset is not a base variable, manifest var, or fingerprint
Unknown base branch base_branch does not resolve to any local or remote ref
Stale worktree A git-registered worktree's directory no longer exists on disk
Fingerprint/var name overlap A [[fingerprint]] name and a [vars] key normalize the same (they use different env prefixes, but may be confusing in templates)
Empty fingerprint files list A [[fingerprint]] has no entries in files
Duplicate fingerprint file The same file path appears more than once within one [[fingerprint]]
Fingerprint length exceeds digest length is larger than the algorithm's full digest size; the full digest is used

Debugging

Set GIT_WORKSPACE_LOG_LEVEL to get diagnostic output on stderr:

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:

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

Run the tests:

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.

Lint and type check:

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

Project layout:

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

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. Contributions and feedback are welcome!

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


Built with Typer, Rich, and a deep appreciation for git worktrees.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

git_workspace_cli-0.6.0b3.tar.gz (38.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

git_workspace_cli-0.6.0b3-py3-none-any.whl (51.0 kB view details)

Uploaded Python 3

File details

Details for the file git_workspace_cli-0.6.0b3.tar.gz.

File metadata

  • Download URL: git_workspace_cli-0.6.0b3.tar.gz
  • Upload date:
  • Size: 38.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for git_workspace_cli-0.6.0b3.tar.gz
Algorithm Hash digest
SHA256 f302cbd2bd026909af6cb13ed777070025e9e6720ef0c554398352be8f5d87fd
MD5 ac03bd6e5a832d4fdad2dd8d5e4b411b
BLAKE2b-256 ac15fe00fde195229846cc974900bd516b4bb9737ec8eab30e91f9b2570d1713

See more details on using hashes here.

Provenance

The following attestation bundles were made for git_workspace_cli-0.6.0b3.tar.gz:

Publisher: pypi.yml on ewilazarus/git-workspace

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file git_workspace_cli-0.6.0b3-py3-none-any.whl.

File metadata

File hashes

Hashes for git_workspace_cli-0.6.0b3-py3-none-any.whl
Algorithm Hash digest
SHA256 e528ab03b1093a1984bca134d511a9c9e616de9fc32c35f0823d63c499371221
MD5 f618e80de00d39825cd23791e7773e15
BLAKE2b-256 f30fdcbff3b057507e7d75b78845e69c91d93c1fe60d870b8e0ded34012b5548

See more details on using hashes here.

Provenance

The following attestation bundles were made for git_workspace_cli-0.6.0b3-py3-none-any.whl:

Publisher: pypi.yml on ewilazarus/git-workspace

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page