An opinionated git plugin that wraps git worktrees with a lifecycle system
Project description
git-workspace
Local environments with zero friction.
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
- Demo
- How it works
- Installation
- Quick start
- Commands
- Workspace manifest
- Lifecycle hooks
- Assets: links and copies
- Pruning stale worktrees
- Detached mode
- Running commands in worktrees
- Diagnosing a workspace
- Debugging
- Development
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
- ๐งญ CWD-aware โ detects when you're already inside a workspace or worktree
- ๐๏ธ Detached mode โ skip interactive hooks for headless, CI, or agent workflows
- ๐ง Exec in context โ run arbitrary commands inside any worktree without switching directories
- ๐งน 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/pipinstall path is in$PATH, so Git can locate thegit-workspaceexecutable.
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 --helpto 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 root |
Print workspace root path; exits 0 if inside a workspace, 1 otherwise |
git workspace exec |
Run an arbitrary command inside a worktree, creating it first if needed |
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 = ["install_deps", "docker build . -t myproj:latest"]
on_attach = ["open_editor"]
on_detach = ["save_state"]
on_teardown = ["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 |
Hook execution order
Hooks come in two pairs that map to the two lifetimes a worktree has:
- Worktree lifetime โ
on_setupandon_teardownbracket the full existence of the worktree directory. - Session lifetime โ
on_attachandon_detachbracket 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; not run by exec) |
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 = ["install_deps", "docker build . -t myproj:latest", "echo ready"]
on_attach = ["open_editor"]
on_detach = ["save_session"]
on_teardown = ["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
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
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)
Running commands in worktrees
exec runs an arbitrary command inside a worktree without you having to cd into it first:
git workspace exec feature/my-feature -- make test
git workspace exec main -- npm run build
git workspace exec hotfix/urgent -r /path/to/workspace -- ./scripts/deploy.sh
The command runs from the worktree root and receives the same GIT_WORKSPACE_* environment variables that lifecycle hooks do.
If the worktree for the given branch doesn't exist yet, exec prompts you to create it first โ it runs on_setup but does not run on_attach or on_detach. Use --force to skip the prompt:
# prompts: "Worktree for branch 'feature/new' does not exist. Create it?"
git workspace exec feature/new -- npm install
# creates the worktree silently and then runs the command
git workspace exec feature/new --force -- npm install
The exit code of the command is propagated โ exec exits with the same code as the command it ran.
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 |
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 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 |
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
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
โโโ git.py โ subprocess wrappers for git
โโโ hooks.py โ lifecycle hook runner
โโโ manifest.py โ manifest parsing
โโโ worktree.py โ worktree model
โโโ workspace.py โ top-level workspace 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file git_workspace_cli-0.6.0b2.tar.gz.
File metadata
- Download URL: git_workspace_cli-0.6.0b2.tar.gz
- Upload date:
- Size: 31.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
368a6d1c4b56a8b75b042cfe2a602eb30ae6c59422d9d9b4b67952fce8d7fe9c
|
|
| MD5 |
5f99c2aaa06edb0461687053af74e800
|
|
| BLAKE2b-256 |
3ff725fd8d2e031bf73bf7d7afd3251436740a3dcb2914c4887abed6c55023d2
|
Provenance
The following attestation bundles were made for git_workspace_cli-0.6.0b2.tar.gz:
Publisher:
pypi.yml on ewilazarus/git-workspace
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
git_workspace_cli-0.6.0b2.tar.gz -
Subject digest:
368a6d1c4b56a8b75b042cfe2a602eb30ae6c59422d9d9b4b67952fce8d7fe9c - Sigstore transparency entry: 1371706146
- Sigstore integration time:
-
Permalink:
ewilazarus/git-workspace@cd8284e168a2928b808d09568f4d3216e1ca8636 -
Branch / Tag:
refs/tags/v0.6.0b2 - Owner: https://github.com/ewilazarus
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yml@cd8284e168a2928b808d09568f4d3216e1ca8636 -
Trigger Event:
push
-
Statement type:
File details
Details for the file git_workspace_cli-0.6.0b2-py3-none-any.whl.
File metadata
- Download URL: git_workspace_cli-0.6.0b2-py3-none-any.whl
- Upload date:
- Size: 43.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
30a04e8e16e1dd34c3fd6656b001c11dad783ec5f987fd5e10c33d7ccb840ba1
|
|
| MD5 |
dff144fa48077fe3cbdcc6815aed47ab
|
|
| BLAKE2b-256 |
2061c4387cc0b6e05bb494b42cc22e00dc118897fa263f4357885812fa0eff6b
|
Provenance
The following attestation bundles were made for git_workspace_cli-0.6.0b2-py3-none-any.whl:
Publisher:
pypi.yml on ewilazarus/git-workspace
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
git_workspace_cli-0.6.0b2-py3-none-any.whl -
Subject digest:
30a04e8e16e1dd34c3fd6656b001c11dad783ec5f987fd5e10c33d7ccb840ba1 - Sigstore transparency entry: 1371706356
- Sigstore integration time:
-
Permalink:
ewilazarus/git-workspace@cd8284e168a2928b808d09568f4d3216e1ca8636 -
Branch / Tag:
refs/tags/v0.6.0b2 - Owner: https://github.com/ewilazarus
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yml@cd8284e168a2928b808d09568f4d3216e1ca8636 -
Trigger Event:
push
-
Statement type: