Git Worktree Wrapper - CLI tool for managing git worktrees with configurable path templates
Project description
English | Русский
⚠️ Warning: GWW is not heavily tested, use at your own risk!
🚀 GWW - Git Worktree Wrapper
A CLI tool that wraps git worktree functionality with configurable path templates, condition-based routing, and project-specific actions.
✨ Features
- 📝 Configurable path templates: Dynamic path generation using templates with functions like
path(n),branch(),norm_branch(),tag() - 🔄 Condition-based routing: Route repositories to different locations based on URI conditions (host, path, protocol, tags)
- 🏷️ Tag support: Pass custom tags via
--tagoption for conditional routing and path organization - ⚙️ Project actions: Execute custom actions (file copies, commands) after clone or worktree creation
- 🐚 Shell completion: Bash, Zsh, and Fish completion support
📋 Requirements
- 🐍 Python 3.11+
- 🔧 Git
- 🖥️ Unix-like system (Linux, macOS)
📦 Installation
Install from PyPI (recommended)
The package is published on PyPI as git-worktree-wrapper; the CLI
command it installs is gww.
Using uv
uv tool install git-worktree-wrapper
gww --help
Using pipx
pipx install git-worktree-wrapper
gww --help
Using pip
python -m pip install --user git-worktree-wrapper
gww --help
From Git (no PyPI)
Use this if you need a specific commit or branch that hasn't been released yet.
Using uv
uv tool install "git+https://github.com/vadimvolk/git-worktree-wrapper.git"
gww --help
Using pipx
pipx install "git+https://github.com/vadimvolk/git-worktree-wrapper.git"
gww --help
From source (development)
# Clone the repository
git clone git@github.com:vadimvolk/git-worktree-wrapper.git
cd git-worktree-wrapper
# Install with uv
uv sync
# Run gww
uv run gww --help
From source using pip
# From a local checkout
cd git-worktree-wrapper
python -m pip install .
gww --help
🚀 Quick Start
1. ⚙️ Initialize Configuration
gww init config
This creates a default configuration file at ~/.config/gww/config.yml (Linux), ~/Library/Application Support/gww/config.yml (macOS), or %APPDATA%\gww\config.yml (Windows). On any platform, exporting XDG_CONFIG_HOME to an absolute path overrides the default and stores the config there instead. Edit these 2 values: default_sources and default_worktrees. Check the tutorial section for routing details.
2. 🐚 Initialize Shell Integration
gww init shell zsh # or bash, or fish
This installs shell completion and aliases (gwc, gwa, gwr) for easier workflow. Follow the instructions printed by the command to enable them in your shell.
3. 📥 Clone a Repository
gwc https://github.com/user/repo.git
# Prompts: "Navigate to ~/Developer/sources/github/user/repo? [Y/n]"
# Navigates if you confirm (default: yes)
4. ➕ Add a Worktree
cd ~/Developer/sources/github/user/repo
gwa feature-branch
# Prompts: "Navigate to ~/Developer/worktrees/github/user/repo/feature-branch? [Y/n]"
# Navigates if you confirm (default: yes)
5. ➖ Remove a Worktree
gwr feature-branch
# If worktree has uncommitted changes or untracked files:
# Prompts: "Force removal? [y/N]"
# Removes with --force if you confirm
# Otherwise: Removes worktree immediately
# Output: Removed worktree: ~/Developer/worktrees/github/user/repo/feature-branch
6. 🔄 Update Source Repository
gww pull
# Output: Updated source repository: ~/Developer/sources/github/user/repo
Note: gww pull updates the source repository even from a worktree, as long as the source is clean and has main or master checked out. Useful for merge/rebase workflows.
gww pull # from any repository worktree
git rebase main # rebase your current changes to updated main branch
7. 🚚 Migrate Repositories
Create a backup first!
gww migrate ~/old-repos --dry-run
# Output:
# Would migrate 5 repositories:
# ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1
# ...
gww migrate ~/old-repos
# Copy (default): list, copy sources then worktrees, repair, summary
gww migrate ~/old-repos --inplace
# Move worktrees then sources, repair, clean empty folders
The migrate command scans one or more directories for git repositories and migrates them to locations based on your current configuration. It's useful when:
- You've updated your configuration and want to reorganize existing repositories
- You're moving from manual repository management to GWW
- You need to consolidate repositories from different locations
Options:
--dry-run,-n: Show what would be migrated without making changes--copy(default): Copy repositories to new locations; list, validate, copy sources then worktrees, rungit worktree repair, then report summary. No folder cleanup.--inplace: Move repositories in place (worktrees first, then sources), rungit worktree repair, then recursively clean empty source folders.
Behavior:
- Accepts one or more paths; scans each and merges repo lists (deduplicated)
- Classifies each repo as source or worktree; uses source path template for sources and worktree path template for worktrees
- --inplace: Two passes (worktrees then sources), move and repair, then remove vacated dirs and empty parents up to input roots
- --copy: List sources and worktrees, validate destinations, copy sources then worktrees, repair relations, report summary
- Skips repositories without remotes, detached HEAD worktrees, or already at target
Tutorial
A minimal config file looks like:
# Folder where all sources are checked out with gwc. path(-2)/path(-1) generates 2-level subfolders based on repository URI. Like https://github.com/user/repo.git -> ~/Developer/sources/user/repo
default_sources: ~/Developer/other/sources/path(-2)/path(-1)
# Folder where all worktrees are checked out with gwa. norm_branch() works better with remote branches, e.g. origin/remote-branch -> origin-remote-branch
default_worktrees: ~/Developer/other/worktrees/path(-2)/path(-1)/norm_branch()
The generated file will have more options commented out, including the functions reference.
Checkout based on where repository is hosted
Useful to separate e.g. open source projects (where you learn or get inspired) from your work projects.
# Still needed in case the config fails to find a section. You may prefer a non-nested sources structure, but make sure the result folder is unique
default_sources: ~/Developer/sources/host()-path(-2)-path(-1)
default_worktrees: ~/Developer/worktrees/host()-path(-2)-path(-1)-norm_branch()
sources:
# ... other rules
work:
when: "your.org.host" in host()
sources: ~/Developer/work/sources/path(-2)-path(-1)
worktrees: ~/Developer/work/sources/path(-2)-path(-1)-norm_branch()
That's enough to separate work sources from all others, but you can create more sections with various rules. The library uses simpleeval to evaluate templates, so you can use its operators and functions below to get necessary routing.
🌐 URI Functions (available in templates and when conditions)
| Function | Description | Example |
|---|---|---|
uri() |
Get full URI string | uri() → "https://loca-repo-manager.com:8081/user/repo.git" |
host() |
Get URI hostname | host() → "loca-repo-manager.com" |
port() |
Get URI port (empty string if not specified) | port() → "8081" or "" usually |
protocol() |
Get URI protocol/scheme | protocol() → "https" / "ssh" / git |
path(n) |
Get URI path segment by index (0-based, negative for reverse) | path(-1) → "repo", path(0) → "user" |
🌿 Branch Functions (available in templates)
| Function | Description | Example |
|---|---|---|
branch() |
Get current branch name | branch() → "feature/new/ui" |
norm_branch(replacement) |
Branch name with / replaced (default: "-") |
norm_branch() → "feature-new-ui", norm_branch("_") → "feature_new_ui" |
Need to checkout temporary projects separately? Add this to your config:
sources:
# ... other rules
temp:
when: tag_exist("temp") # See [tags section](#-tags) for details about tags
sources: ~/Downloads/temp/sources/time_id()-host()-path(-2)-path(-1)
worktrees: ~/Downloads/temp/worktrees/time_id()-host()-path(-2)-path(-1)-norm-branch()
time_id(fmt) generates a datetime-based identifier (cached per template evaluation). Default format is "20260120-2134.03" (short, seconds accuracy unique). Use format codes for more detailed/nested results. Works properly if used multiple times.
worktrees: ~/Downloads/temp/worktrees/time_id("%Y")/time_id("%m")/time_id("%H-%M$.%S")/host()-path(-2)-path(-1)-norm-branch()
Generates nested structure: YYYY/HH-MM.ss/host()-path(-2)-path(-1)-norm-branch()
⚙️ Actions (available in actions section)
Run actions after checking out a repository, after adding a worktree, or before removing a worktree. Common example: copying local.properties for Gradle projects.
actions:
- when: file_exists("settings.gradle") # Check if it's actually a Gradle project
after_clone:
- copy: ["~/sources/default-local.properties", "local.properties"] # Copies your default file right after cloning the repo
after_add:
- copy: ["source_path('local.properties')", "local.properties"] # Inherit existing repository file to worktree
You can have multiple when subsections in actions. After clone/add, the library goes top-to-bottom and executes all actions with matching when conditions.
Other functions available in the actions section:
| Action | Description | Example |
|---|---|---|
copy |
Copy a file or directory tree from a template-evaluated source to a template-evaluated destination (relative to current_worktree()) |
copy: ["source_path('local.properties')", "local.properties"] or copy: ["~/sources/default-local.properties", "local.properties"] |
command |
Execute external command (runs in destination directory, template functions available) | command: "npm install" or command: "claude init" |
Each action rule also accepts an optional critical: flag (default true). When true, a failing action in that rule aborts the remaining actions in the rule and makes the command exit 1. Set critical: false for non-critical rules — failures are reported in the action execution summary but the command still exits 0. See Failure handling below for the full exit-code table.
actions:
- when: file_exists("package.json")
critical: false # non-critical: a missing node_modules is annoying, not fatal
after_clone:
- command: "npm install"
🪓 before_remove — cleanup actions before gww remove
A third action kind, before_remove, runs user-defined cleanup steps before git worktree remove deletes the worktree. Use it to archive the worktree, post a notification, run a hook, etc. The same critical: / command: / copy: machinery applies; a critical before_remove failure aborts the remove and exits 1, a non-critical failure is reported but the remove still proceeds. --force is git-only and does not bypass before_remove.
In before_remove rules, current_worktree() is the worktree being removed, source_path() is its parent source repo, and branch() resolves to the worktree's currently checked-out branch (or "" on detached HEAD). The gww remove command also accepts --tag key=value, which flows into tag() / tag_exist() predicates so per-call cleanup can be tag-driven.
actions:
# Critical: archive the worktree before letting `gww remove` delete it.
- when: 'tag_exist("archive")'
before_remove:
- command: "tar -czf ~/archives/norm_branch()-time_id('%Y%m%d').tar.gz current_worktree()"
# Non-critical: notify a Slack channel. A failed notification should not block the remove.
- when: tag("notify") == "slack"
critical: false
before_remove:
- command: "curl -sf -X POST https://hooks.slack.com/... -d branch=branch()"
# Path-based remove: when removing by absolute path, `branch()` still resolves.
- when: branch() == "main"
before_remove:
- command: "echo refusing to remove main branch"
Note:
gww removeaccepts an absolute worktree path as well as a branch name. When invoked with a path,branch()reads the worktree's current branch viagit rev-parse --abbrev-ref HEADand falls back to""for detached HEAD, so predicates referencingbranch()never raise.
❗ Failure handling
Action failures are reported per-rule, grouped at the end of the action loop on stderr as the action execution summary. The summary lists every failing rule by its index in actions:, its criticality flag, and the failing action's error. Its non-emptiness also gates the success line: say() (the line scripts cd $(gwc …)) is suppressed whenever the summary has any entry, so cd only ever lands on a fully-configured worktree.
| Outcome | Exit code |
|---|---|
| Clean run (no failures) | 0 |
| Non-critical rule failure only | 0 |
| Critical rule failure | 1 |
when: predicate or command: template failed to evaluate |
2 |
📁 Actions Functions (available in command actions and when conditions)
| Function | Description | Example |
|---|---|---|
source_path(extra?) |
Get absolute path to source repository, optionally joined with extra |
source_path() → "/path/to/repo", source_path("local.properties") → "/path/to/repo/local.properties" |
current_worktree(extra?) |
Get absolute path to the current worktree, optionally joined with extra |
current_worktree() → "/path/to/worktree", current_worktree("local.properties") → "/path/to/worktree/local.properties" |
file_exists(path) |
Check if file exists relative to source repository | file_exists("local.properties") → True |
dir_exists(path) |
Check if directory exists relative to source repository | dir_exists("config") → True |
path_exists(path) |
Check if path exists (file or directory) relative to source repository | path_exists("local.properties") → True |
🏷️ Tags
Still not flexible enough? Here comes tags. Tags specified using command line param -t <tag-name>[=optional value] (or --tag) for clone / add commands. Tags available in configuration with:
| Function | Description | Example |
|---|---|---|
tag(name) |
Get tag value by name (returns empty string if not set) | tag("env") → "prod" |
tag_exist(name) |
Check if tag exists (returns boolean) | tag_exist("env") → True |
🏷️ Tag Usage Example:
sources:
# Temporary checkout: Clone repositories to ~/Downloads/temp for quick access
# Usage: gwc <uri> -t temp
temp:
when: 'tag_exist("temp")'
sources: ~/Downloads/temp/time_id()-host()-path(-1)
worktrees: ~/Downloads/temp/time_id()-host()-path(-1)/norm_branch()
# Code review worktrees: Add worktrees to ~/Developer/worktree/code-review for review tasks
# Usage: gwa <branch> --tag review
review:
when: 'tag_exist("review")'
worktrees: ~/Developer/review/worktree/path(-1)/norm_branch()
# If used during clone, default source path is used
```bash
# Clone to temporary location
gwc https://github.com/user/repo.git -t temp
# Output: ~/Downloads/temp/repo
# Add worktree for code review
cd ~/Developer/sources/github/user/repo
gwa feature-branch --tag review
# Output: ~/Developer/worktree/code-review/repo/feature-branch
📖 Commands
| Command | Description |
|---|---|
gwc <uri> [--tag key=value]... |
📥 Clone repository to configured location (tags available in templates/conditions) |
gwa <branch> [-c] [--tag key=value]... |
➕ Add worktree for branch (optionally create branch, tags available in templates/conditions) |
gwr <branch|path> [-f] [--tag key=value]... |
➖ Remove worktree (tags available in before_remove predicates) |
gww pull |
🔄 Update source repository (works from worktrees if source is clean and on main/master) |
gww clean [--merged|--all] [--dry-run] [--yes] [--force] |
🧹 Remove worktrees whose branch has been merged (provider or git fallback) |
gww migrate <path>... [--dry-run] [--copy | --inplace] |
🚚 Migrate repositories to new locations |
gww init config |
⚙️ Create default configuration file |
gww init shell <shell> |
🐚 Install shell completion (bash/zsh/fish) |
Note: gwc, gwa, and gwr are convenient shell aliases for gww clone, gww add, and gww remove respectively. They provide the same functionality with automatic navigation prompts. Install them with gww init shell <shell>.
Common Options:
--tag,-t: Tag in the formatkey=valueor justkey(can be specified multiple times).
🧹 gww clean
Removes worktrees (and their local branches) from the current source repository whose branch satisfies the active filter. The main checkout and the source's default branch are never removed.
gww clean [--merged|--all] [--dry-run] [--yes|-y] [--force]
| Flag | Behaviour |
|---|---|
--merged (default) |
Per cleanable worktree, evaluate the provider's merged command (or git branch --merged <default> as a fallback). Remove if exit 0, leave in place otherwise. |
--all |
Skip MR-status checks. Every cleanable worktree is subject to confirmation. |
--dry-run |
Run the full flow with no side effects and no prompt. |
--yes / -y |
Skip the batch confirmation prompt. |
--force |
Pass --force to git worktree remove and use -D instead of -d for git branch. Does NOT escalate the MR filter. |
Targeted removal is gww remove <branch>. gww clean never deletes remote branches.
Output (per branch):
checking feature-a
feature-a: clean # or "keep", "skip (timeout)", "skip (gh not found)"
Confirmation prompt (skipped by --yes / --dry-run):
Filter: --merged (provider: github). Delete matching worktrees? [y/N]
The --merged token becomes --all when that filter is active; the (provider: <name>) suffix appears only when --merged is active AND a provider resolved, otherwise the prompt is just Filter: --merged. Delete matching worktrees? [y/N]. Answer y (case-insensitive) to proceed; anything else (including EOF) declines with no side effects and exit code 0.
Summary (end of run):
Removed N; kept M
# or with --dry-run:
Would remove N; would keep M
Zero counts are omitted; ; F failed and/or ; T timed out are appended only when non-zero.
Exit codes:
0— confirmation accepted (or--yes), command ran to completion with no per-worktree git failures. Confirmation rejected / EOF is also0.1— any per-worktreegit worktree removeorgit branch -dfailed.2— config error (validation, missing required field).
Provider failures do not affect the command's exit code. The summary's kept M count is the same whether a token-expired gh produced 1s or whether the repo genuinely has no merged MRs.
🔌 Providers
To enable provider-aware merged-MR filtering, declare a providers: block in your config.yml with a when predicate and a filter command template for each entry:
providers:
github:
when: '"github" in host()'
filter: 'gh pr list --head branch() --state tag("state", "merged") --json number --jq "length > 0"'
gitlab:
when: '"gitlab" in host()'
filter: 'glab mr list --source-branch branch() --state tag("state", "merged")'
gitea:
when: '"codeberg.org" in host()'
filter: 'tea pulls list --head branch() --state closed --output json | jq -e "[.[] | select(.merged or tag(\"state\") == \"closed\")] | length > 0"'
Resolution: gww clean evaluates each declared provider's when predicate against the source's URI in config order; first match wins — the same mechanism sources: rules use. Provider names are free-form. No GWW_PROVIDER env override. No auto-applied built-in defaults — users on hosted instances must declare the provider in their config, or rely on the --merged git fallback (git branch --merged <default>) when no provider matches. The commented providers: block in gww init config ships the worked examples above; there are no per-kind reference modules in the codebase.
Contract: the rendered filter command must exit 0 iff the branch is cleanable (e.g., an MR/PR for the worktree's branch is in the merged state). gww clean reads only the exit code; it never parses the provider's stdout or stderr. The provider's stdout and stderr are passed through to your terminal live.
Template functions available in providers.<name>.filter: branch(), host(), port(), protocol(), uri(), path(n), tag(name, default), tag_exist(name), plus project-context helpers (source_path(), current_worktree(), file_exists(), etc.).
Tag-driven filtering: Use gww clean --tag key=value to pass tags into both the provider's when predicate (for selection) and the filter template (for per-branch logic):
# Clean merged branches (default)
gww clean
# Clean closed branches instead
gww clean --tag state=closed
# Clean draft PRs
gww clean --tag state=draft
🔄 Update
Using uv
# Re-run the install command to update to the latest version
uv tool install "git+https://github.com/vadimvolk/git-worktree-wrapper.git"
# Or use the update command (if available)
uv tool update gww
Using pipx
pipx upgrade gww
Using pip
python -m pip install --upgrade gww
🗑️ Uninstall
Using uv
uv tool uninstall gww
Using pipx
pipx uninstall gww
Using pip
python -m pip uninstall gww
🛠️ Development
🧪 Running Tests
# Run all tests
uv run pytest
# Run with coverage
uv run pytest --cov
# Run only unit tests
uv run pytest tests/unit/
# Run only integration tests
uv run pytest tests/integration/
🔍 Type Checking
uv run mypy src/gww
📄 License
MIT
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
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_worktree_wrapper-0.3.0.tar.gz.
File metadata
- Download URL: git_worktree_wrapper-0.3.0.tar.gz
- Upload date:
- Size: 223.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
74aac40174b52effd0832f6fba759d1f7551d22b2aee2c1d7a42811e17a3495a
|
|
| MD5 |
b073a8b4084895b1a4f065da5534730c
|
|
| BLAKE2b-256 |
c342be897fcf3aa33074c05963a5fa82afd3b645f7b534c6c9420196ee934ea6
|
Provenance
The following attestation bundles were made for git_worktree_wrapper-0.3.0.tar.gz:
Publisher:
release.yml on vadimvolk/git-worktree-wrapper
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
git_worktree_wrapper-0.3.0.tar.gz -
Subject digest:
74aac40174b52effd0832f6fba759d1f7551d22b2aee2c1d7a42811e17a3495a - Sigstore transparency entry: 2175496422
- Sigstore integration time:
-
Permalink:
vadimvolk/git-worktree-wrapper@be85e7ef4ef022138f32f3a9326c7a5199e51978 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/vadimvolk
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@be85e7ef4ef022138f32f3a9326c7a5199e51978 -
Trigger Event:
push
-
Statement type:
File details
Details for the file git_worktree_wrapper-0.3.0-py3-none-any.whl.
File metadata
- Download URL: git_worktree_wrapper-0.3.0-py3-none-any.whl
- Upload date:
- Size: 82.2 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 |
af92256d61259672c7f6d5128c1178cdcdd17e699240ddcfc017e91128e90000
|
|
| MD5 |
b62149a50776ca14aa9491e44566353c
|
|
| BLAKE2b-256 |
781ca45f0f43640246316b18a79bc5e9575530d3a8ab13f890562bde9bf88ef8
|
Provenance
The following attestation bundles were made for git_worktree_wrapper-0.3.0-py3-none-any.whl:
Publisher:
release.yml on vadimvolk/git-worktree-wrapper
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
git_worktree_wrapper-0.3.0-py3-none-any.whl -
Subject digest:
af92256d61259672c7f6d5128c1178cdcdd17e699240ddcfc017e91128e90000 - Sigstore transparency entry: 2175496774
- Sigstore integration time:
-
Permalink:
vadimvolk/git-worktree-wrapper@be85e7ef4ef022138f32f3a9326c7a5199e51978 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/vadimvolk
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@be85e7ef4ef022138f32f3a9326c7a5199e51978 -
Trigger Event:
push
-
Statement type: