Turn a branch or PR into a ready-to-work, isolated git worktree — dependencies
reused instead of reinstalled, unversioned .env files copied, and an AI agent
session launched already primed on what changed and why. The git plumbing, dep
setup, and context injection are all handled transparently. The GitHub PR view
gives you a diff; tackle gives you a conversation.
Also useful for parallel branch work: spin up a second checkout without stashing or switching branches.
Layout:
tackle.zsh(the script),tests/(bats suite + zsh smoke test — runtests/run.sh), andSELL.md(a shareable write-up). Source it from your shell rc:source ~/code-private/tackle/tackle.zsh
gwtis a kept alias fortackle(the tool's original name), so old muscle memory still works.
tackle <branch> # create worktree, install deps, launch agent
tackle <PR-number> # resolve branch from PR number, then same
tackle <PR-url> # resolve branch from PR URL, then same
tackle <branch> --no-agent # create worktree + install deps, no agent
tackle <branch> -na # same as --no-agent
tackle --new <branch> # create a NEW branch off HEAD, then same
tackle -n <branch> --base <ref> # create a new branch off <ref> (short: -n / -b)
tackle <branch> --install # force isolated install even if lockfile unchanged
tackle <branch> --no-deps # skip all dependency handling (no symlink, no install)
tackle <branch> --no-env # skip copying unversioned .env files into the worktree
tackle <branch> --no-config # ignore any project tackle.toml for this run
tackle <branch> --trust # pre-approve the project config's hook commands
tackle <branch> --time # prefix each step with a [HH:MM:SS] timestamp
tackle <PR-url> --repo-check remote # verify the URL's repo vs this checkout via gh
tackle <branch> --review # launch agent with built-in "what changed?" prompt
tackle <branch> --prompt "message" # launch agent with a custom initial prompt
tackle <branch> --add "message" # add to prompt at {additive_prompt}, or append (stackable)
tackle <branch> --before "message" # always prepend to the active prompt (stackable)
tackle <branch> --after "message" # always append to the active prompt (stackable)
tackle --done # (from inside worktree) cd back + remove worktree
tackle --close # alias for --done
PR resolution — tackle accepts a branch name, PR number, or PR URL. When a
plain branch name is given, it automatically queries gh pr list --head <branch>
for open PRs:
- 1 PR found — auto-resolved silently; PR number, title, and description are available as template variables
- Multiple PRs —
fzfpicker if available; otherwise an error listing the first 10 with ready-to-runtackle <number>hints - 0 PRs found — continues silently in branch-only mode (no PR context)
If TACKLE_DIR_TEMPLATE references {pr_number} or {pr_title} and no PR could
be resolved, tackle errors early rather than creating a misleadingly-named
worktree.
Cross-repo guard (--repo-check / TACKLE_REPO_CHECK) — a PR URL carries
its own owner/repo. If it names a different GitHub repo than the current
checkout, tackle would resolve the PR from the URL's repo but check out its head
branch here — a fetch failure at best, a silent same-named-branch mismatch at
worst. tackle catches this up front. Modes:
local(default) — string-compare the URL againstgit remote get-url origin, normalized so scp-form (git@github.com:o/r.git) and https-form compare equal. Zero network.remote— askghfor this checkout's canonical identity (handles renamed repos / non-originremotes); falls back to the local check whenghis unavailable, unauthenticated, or offline.off— skip the guard.
Bare PR numbers carry no repo info and always skip the guard. Select per-run
with --repo-check=<mode> or persistently with TACKLE_REPO_CHECK.
Dependency behaviour (language-agnostic): tackle detects every package manager present in the worktree — so multilingual repos are handled, not just the first ecosystem found. For each one it makes a per-ecosystem decision driven by the lockfile, which is the reliable, cross-language signal for "did deps change on this branch" (never a comparison of the installed tree):
- Reuse (symlink) — instant, zero-copy — used only when all hold: the
ecosystem's install dir is safely shareable by a plain root symlink (flat trees:
npm/yarn), the lockfile(s) are byte-identical between the main repo and the
worktree, and the main tree exists and is non-empty. The symlink is added to
.git/info/excludeso git doesn't show it as untracked. A warning notes that dep mutations here (e.g. adding a package) affect the shared main-repo tree. - Install — used otherwise (lockfile differs, or the layout isn't safely root-symlinkable). tackle runs the ecosystem's own install command, which is fast off its warm global cache (pnpm hardlinks, cargo/go/pip caches — no re-download).
The registry (add a package manager = add a row):
| Ecosystem | Detected by | Reuse dir | Root-symlinkable? | Install |
|---|---|---|---|---|
| pnpm | pnpm-lock.yaml |
node_modules |
No¹ | pnpm i |
| yarn | yarn.lock |
node_modules |
Yes | yarn |
| npm | package-lock.json / package.json |
node_modules |
Yes | npm i |
| Rust (cargo) | Cargo.lock |
target |
No² | cargo fetch |
| Go | go.sum |
— | No² | go mod download |
| Python (poetry) | poetry.lock |
.venv |
No³ | poetry install |
| Python (uv) | uv.lock |
.venv |
No³ | uv sync |
| Python (pip) | requirements.txt |
.venv |
No³ | pip install -r requirements.txt |
¹ pnpm's strict/nested node_modules (one root + one per workspace package) isn't
fully materialised by a single root symlink, so pnpm defaults to install. Opt in to
symlink-reuse for known-flat layouts with TACKLE_PNPM_SYMLINK=true, or a
node-linker=hoisted line in .npmrc / nodeLinker: hoisted in
pnpm-workspace.yaml. ² cargo/go materialise from a shared global cache, so a
build in the worktree is already fast; there's no in-tree tree worth symlinking.
³ Python virtualenvs bake absolute paths and are never relocatable, so they are
never symlinked.
Bazel workspaces (MODULE.bazel / WORKSPACE / WORKSPACE.bazel) skip in-tree
dependency handling entirely: Bazel owns out-of-tree caches (e.g.
~/Library/Caches/bazel) and the in-tree node_modules/.venv/target are empty
by design, so there's nothing to reuse or install.
Pass --install to force a fully isolated install even when a symlink would be
valid, or --no-deps (or TACKLE_DEPS=off) to skip dependency handling entirely.
Known limitation: only root-level lockfiles are compared; nested per-package
workspace lockfiles are out of scope.
.env copy behaviour: git worktree add only materialises tracked files,
so gitignored secrets like .env would be missing and the app couldn't build or
run. After creating the worktree, tackle walks the main repo for files named
.env or .env.* — pruning package-manager/VCS dirs (node_modules, .git,
vendor, .venv, venv, __pycache__, .tox, .pnpm-store) for speed — and
copies each to its twin path in the worktree, creating parent directories as
needed. A .env buried deep in the tree (e.g. apps/web/.env.local) lands at
the same relative path; this filesystem walk also catches env files inside
gitignored directories, which a git ls-files approach would miss. Two skip
conditions apply. First, the twin path already exists: a tracked file has
already been checked out, so its presence means "leave it alone" — the copy is
strictly additive and never overwrites (this alone handles committed templates,
which are almost always tracked). Second, template files are excluded by
name — anything ending in example, sample, template, or .dist (so
.env.example, .env.local-example, .env.sample, .env.template,
.env.dist) is never copied, even if it happens to be untracked; these carry no
secrets and are meant to be committed. Unlike node_modules (symlinked), env
files are copied, so per-worktree tweaks don't leak back into the main
checkout. Each copied file is logged with its full destination path (followed by
a summary count), so you can see exactly which secrets landed in the worktree.
Disable per-run with --no-env, or persistently with TACKLE_COPY_ENV=false.
The worktree is created at ../<template> (default: <repo>_<branch>). If the
branch doesn't exist locally it's fetched from origin first.
--new / -n — start a new branch. By default tackle only works with
branches that already exist locally or on origin. Pass --new to create the
branch as part of spinning up the worktree — the equivalent of
git worktree add -b <branch>:
tackle --new feature/my-idea # new branch off HEAD, worktree, deps, agent
tackle -n feature/my-idea --base main # new branch off main instead of HEAD--base / -b <ref> picks what to branch from (default: HEAD); it's only
valid together with --new. In --new mode tackle skips all PR resolution (a
brand-new branch has no PR) and errors early if the branch already exists (drop
--new to check it out instead) or if --base names an unknown ref.
Everything above is personal config (env vars + a co-located .env). A
tackle.toml committed at the repo root lets a project declare how to bring
a worktree up to "running" — extra files to materialise and setup/teardown
commands — so a fresh worktree lands ready to work, not just checked out. tackle
walks from your cwd up to the repo root to find it (so a monorepo subdir works),
and a gitignored tackle.local.toml deep-merges on top for personal
overrides (any key you set there replaces the base). A tackle.json /
tackle.local.json with the same keys works too, if you'd rather not rely on a
TOML parser (tackle uses Python's tomllib, 3.11+).
# tackle.toml — committed at the repo root
agent = "claude" # per-project TACKLE_* defaults
dir_template = "{repo}_{branch}"
prompt = "/pr-review"
deps = "off" # skip dependency handling for this repo
copy = ["config/local.json", "certs/dev.pem"] # copied main repo → worktree
symlink = ["big-assets"] # symlinked main repo → worktree
[hooks]
pre_create = ["docker compose config -q"] # in the MAIN repo, before create; failure aborts
setup = ["pnpm build", "docker compose up -d db"] # in the WORKTREE, after create; failure warns
on_done = ["docker compose down"] # in the worktree on --done, before removal- Keys.
agent/dir_template/prompt/depsmap to theTACKLE_*knobs.copy/symlinkbring files the.envcopy won't (paths are relative to the repo root and must stay inside it — no absolute or..paths).[hooks]are shell commands run with$TACKLE_MAIN,$TACKLE_WORKTREE, and$TACKLE_BRANCHexported. - Precedence (high → low): CLI flag → caller env var →
tackle.local→ basetackle→ personal.env→ built-in default. So a project can set its own agent/prompt, and you can still override per-invocation. - Hook failures:
pre_createis fatal (aborts before the worktree is created);setupandon_donewarn and continue (the worktree already exists — better to let you fix it than tear it down). - Trust. Because a committed file that runs commands is a code-execution
surface, hook execution is gated by a trust-on-first-use prompt (à la
direnv allow). The first time a repo's hooks would run, tackle shows them and asks[o]nce / [a]lways / [s]kip;alwaysremembers the config's fingerprint underTACKLE_STATE_DIR(default~/.local/state/tackle). If the config later changes — including via thetackle.locallayer or agit pull— the fingerprint mismatches and tackle re-prompts with a diff of what changed, so a slipped-in command can't run silently. Non-interactive runs (no TTY / closed stdin) skip hooks rather than auto-run or hang. Config-only keys (agent, template,copy/symlink) always apply — only command execution is gated. - Escape hatches:
--no-config(orTACKLE_CONFIG=off) ignores the file for a run;--trustpre-approves the hooks non-interactively (useful in scripts). - gitignore: add
tackle.local.*to your.gitignore. tackle never edits your git config, but it prints a one-line reminder if it loads atackle.local.*that isn't ignored.
See tackle.example.toml for a fully commented template.
--done / --close uses git worktree list to locate the main repo and cd
back to it. If there are uncommitted changes it lists them and prompts for
confirmation before discarding.
--review flag launches the agent with a built-in prompt that asks it to
summarise what changed, the goal of the changes, and anything worth a closer
look. PR description is injected as context when available — which includes plain
branch inputs that auto-resolve to a single PR. Degrades gracefully when no PR
context is found.
--prompt "message" flag sets a fully custom initial prompt, replacing
--review / TACKLE_PROMPT.
--add "message" flag places extra context at the {additive_prompt} marker
in the active prompt if one is present; otherwise appends to the end. Stackable —
pass multiple --add flags.
--before "message" / --after "message" flags wrap the fully-prepared
prompt from the outside — --before prepends, --after appends — regardless of
template markers. Stackable.
Assembly order: [--before] [base + --add] [--after]
# Built-in review with extra focus (--add becomes part of the base):
tackle 1234 --review --add "Focus on the authentication changes"
# Strict framing — preamble before, instruction after:
tackle 1234 --review --before "You are a senior security reviewer." --after "Keep it concise."
# Use {additive_prompt} in TACKLE_PROMPT to control where --add lands within the base:
export TACKLE_PROMPT="Review this PR.\n\n{additive_prompt}\n\n{pr_description}"
tackle 1234 --add "Flag anything security-related"Prompt template variables — usable in --prompt, --add, --before,
--after, --review, and TACKLE_PROMPT:
| Variable | Value |
|---|---|
{branch} |
Resolved branch name |
{pr_number} |
PR number (PR input only, else empty) |
{pr_title} |
PR title (PR input only, else empty) |
{pr_description} |
PR body wrapped in <pr_description> XML tags (PR input only; only fetched when this variable is present in the prompt) |
{additive_prompt} |
Insertion point for --add content; if absent, --add appends to end |
The XML wrapping on {pr_description} signals to the model that it is external
data, not instructions — a basic prompt-injection defence.
Variables that resolve to empty (e.g. {pr_description} on a branch-only input)
are removed along with any surrounding lines that become blank or
punctuation-only, so the prompt stays clean regardless of input type.
Note: if TACKLE_DIR_TEMPLATE references {pr_number} or {pr_title},
passing a plain branch name is an error — those placeholders would silently
produce a misleading directory name. Pass a PR number/URL instead, or update the
template.
export TACKLE_PROMPT="PR #{pr_number}: {pr_title}
{pr_description}
Summarize what changed and flag anything risky."TACKLE_PROMPT env var sets a persistent default prompt used on every run.
--prompt and --review override it; --add always appends regardless of
source. For agents that support slash commands (e.g. Claude Code), you can invoke
a skill directly:
export TACKLE_PROMPT="/pr-review"--time flag prefixes every output line with a [HH:MM:SS] wall-clock
timestamp.
Output colours (TTY only — stripped when piped): cyan for steps (→), green
for success (✓), yellow for warnings (⚠), red for errors (✗) on stderr.
| Variable | Default | Description |
|---|---|---|
TACKLE_AGENT |
claude |
Agent binary to launch |
TACKLE_DIR_TEMPLATE |
{repo}_{branch} |
Worktree directory name template; placeholders: {repo} {branch} {input} |
TACKLE_PROMPT |
(none) | Default prompt; overridden by --prompt / --review; extended by --add |
TACKLE_REPO_CHECK |
local |
Cross-repo guard for PR URLs: local / remote / off |
TACKLE_DEPS |
on |
Set off to skip all dependency handling (same as --no-deps on every run) |
TACKLE_PNPM_SYMLINK |
(unset) | Set true to allow symlink-reuse of a pnpm node_modules (only safe for flat/hoisted pnpm layouts) |
TACKLE_ENV_FILE |
(script dir)/.env | Path to an env file to source at startup; see below |
TACKLE_COPY_ENV |
true |
Copy unversioned .env/.env.* files into the worktree; set false (or pass --no-env) to skip |
TACKLE_CONFIG |
on |
Set off to ignore any project tackle.toml (same as --no-config on every run) |
TACKLE_STATE_DIR |
~/.local/state/tackle |
Where the project-config trust store lives |
Env file — tackle auto-sources a .env in the same directory as
tackle.zsh if one exists. This is where you keep your personal defaults without
cluttering your shell rc. Copy .env.example to get started. Override the path
by setting TACKLE_ENV_FILE before sourcing the script:
# ~/.zshrc — point to a custom location (optional; omit to use the auto-detected default):
export TACKLE_ENV_FILE="$HOME/.config/tackle.env"
source ~/code-private/tackle/tackle.zshCaller-set env vars always win over the file, so one-shot overrides still work:
# In your .zshrc / .bashrc (or in the co-located .env):
export TACKLE_AGENT="cursor"
export TACKLE_DIR_TEMPLATE="{repo}-wt_{input}"
export TACKLE_PROMPT="/pr-review"
# One-shot override — wins over both shell rc and .env:
TACKLE_DIR_TEMPLATE="{repo}-wt_{input}" tackle 1234# Review a PR — agent starts with full PR context and "what changed?" prompt
tackle 1234 --review
# Tailor the review focus with --add:
tackle 1234 --review --add "Pay close attention to the API surface changes"
# When done, from inside the worktree:
tackle --close # or tackle --done
# Work on a second ticket in parallel without leaving your current branch:
tackle feature/my-branch --no-agentCompatible with bash and zsh — source from .bashrc or .zshrc. Requires git,
python3, and the configured agent binary in PATH. Dependency handling is
language-agnostic: the registry covers JS (pnpm/yarn/npm), Rust, Go, and Python,
plus Bazel-awareness (see Dependency behaviour above); whichever package
managers a repo uses must be on PATH for the install path to run. gh unlocks PR
resolution and the remote cross-repo check; fzf unlocks the multi-PR picker.
./tests/run.sh # bats suite (bash) + zsh smoke testSee tests/README.md for the suite layout and how the hermetic gh/agent stubs
work.
MIT © Robert S.