Skip to main content

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

CI

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 --tag option 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, run git worktree repair, then report summary. No folder cleanup.
  • --inplace: Move repositories in place (worktrees first, then sources), run git 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 remove accepts an absolute worktree path as well as a branch name. When invoked with a path, branch() reads the worktree's current branch via git rev-parse --abbrev-ref HEAD and falls back to "" for detached HEAD, so predicates referencing branch() 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 format key=value or just key (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 also 0.
  • 1 — any per-worktree git worktree remove or git branch -d failed.
  • 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

git_worktree_wrapper-0.3.0.tar.gz (223.0 kB view details)

Uploaded Source

Built Distribution

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

git_worktree_wrapper-0.3.0-py3-none-any.whl (82.2 kB view details)

Uploaded Python 3

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

Hashes for git_worktree_wrapper-0.3.0.tar.gz
Algorithm Hash digest
SHA256 74aac40174b52effd0832f6fba759d1f7551d22b2aee2c1d7a42811e17a3495a
MD5 b073a8b4084895b1a4f065da5534730c
BLAKE2b-256 c342be897fcf3aa33074c05963a5fa82afd3b645f7b534c6c9420196ee934ea6

See more details on using hashes here.

Provenance

The following attestation bundles were made for git_worktree_wrapper-0.3.0.tar.gz:

Publisher: release.yml on vadimvolk/git-worktree-wrapper

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_worktree_wrapper-0.3.0-py3-none-any.whl.

File metadata

File hashes

Hashes for git_worktree_wrapper-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 af92256d61259672c7f6d5128c1178cdcdd17e699240ddcfc017e91128e90000
MD5 b62149a50776ca14aa9491e44566353c
BLAKE2b-256 781ca45f0f43640246316b18a79bc5e9575530d3a8ab13f890562bde9bf88ef8

See more details on using hashes here.

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

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