Skip to content
nnutterPublic

About

I’m not context-switching. I’m worktree-chopping.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

timber

timber manages Git worktrees from registered bare repositories.

There is no required “main” worktree. Repositories are stored as bare Git directories, and worktrees are created on demand under a shared root:

<worktree-root>/<repo-name>/<worktree-name>/<repo-short-name>

Repository names may be structured with / (e.g. nnutter/timber). The worktree leaf keeps only the short name (the last segment).

Defaults:

  • bare repos: $TIMBER_WORKTREE_ROOT/<repo-name>/.git (fallback: ~/worktrees/<repo-name>/.git)
  • worktrees: $TIMBER_WORKTREE_ROOT/<repo-name>/<worktree-name>/<repo-short-name> (fallback: ~/worktrees/<repo-name>/<worktree-name>/<repo-short-name>)

Existing installs keep bare repositories under $XDG_DATA_HOME/timber/repos (fallback: ~/.local/share/timber/repos). timber repo fix moves them into the worktree root and repairs linked worktrees.

For Timber-created worktrees, the worktree name and branch name are identical (including /).

Timber-created branches track the repository's default branch, so the upstream names the base branch of the worktree's pull request. A plain git push would then push to that tracked branch, so each registered repository records push.default=current: git push sends the checked-out branch to the branch of the same name on the remote, and leaves tracking alone. timber repo add, repo init, and repo import record the setting; timber repo fix records it in repositories registered before it existed.

Pass an optional second argument to create a worktree at an exact checkout path instead of using the shared root:

timber create feature/login@timber /path/to/checkout
timber create feature/login@timber ../checkout
timber create @timber ../checkout # generated name

Branch-attached worktrees created directly with Git are also visible when linked to a registered repository. Other manual worktrees use their checkout directory name; conflicts within a repository are resolved by adding parent directory components until the name is unique, reserving Timber-created names. For example, two checkout directories can become one/checkout and two/checkout. These derived names can change when conflicting worktrees are added or removed. Detached worktrees are not listed or managed.

Example:

  • repo name: timber
  • bare repo: ~/worktrees/timber/.git
  • branch: nn/my-feature
  • worktree path: ~/worktrees/timber/nn/my-feature/timber

Structured example (t repo add nnutter/timber defaults to the structured name; GitHub URLs keep owner/repo, other hosted paths keep their full grouping):

  • repo name: nnutter/timber
  • bare repo: ~/worktrees/nnutter/timber/.git
  • branch: structured-names
  • worktree path: ~/worktrees/nnutter/timber/structured-names/timber

Rename with timber repo rename <old> <new> to move between simple and structured names; standard-layout worktrees move with the repo. Sorting timber list --sort repo orders by the full structured name, so the grouping prefix controls grouping.

Use timber repo import <path> to register an existing clean clone and recreate its worktrees (including the former main checkout) in this layout. When invoked through the shell wrapper (t repo import <path>), the shell also cds to $HOME after success.

Use timber repo init <name> to create a local repository without a remote. It creates the bare repository with one empty commit on main, or on -b/--initial-branch <branch>, so worktrees can be created before the repository has any history. Because there is no origin, created worktree branches have no upstream: timber remove needs --force for them until you add a remote.

Demo

timber worktree lifecycle

Register a repository, create a worktree, inspect it with list, step inside it, and remove it again. The still below shows list reporting a merged, clean worktree:

timber list output

Prefer video? docs/demo/demo.mp4 shows the same run. All three are generated from docs/demo/demo.tape; re-render them with mise run demo after changing the tape.

Installation

Install using Go,

go install github.com/nnutter/timber@latest

timber requires Git on PATH.

Install the Pi review extension and timber-review skill:

pi install https://github.com/nnutter/timber

Select and install the Timber skills for your agents with the skills CLI:

npx skills add https://github.com/nnutter/timber

Configuration

Run timber config to show settings and create the config file if absent. The file is $XDG_CONFIG_HOME/timber/config.toml (fallback: ~/.config/timber/config.toml). Automatic mise trust and automatic hk hook installation are disabled by default. On the first eligible worktree creation in an interactive terminal, Timber offers to enable each feature. The prompts default to No and save your answers, so Timber does not ask again. After No, a prompt shows the matching timber config flag so you can enable the feature later. An explicit --no-auto-mise-trust or --no-auto-hk-install also suppresses its prompt. Non-interactive creation does not prompt or record a choice.

timber config --auto-mise-trust
timber config --no-auto-mise-trust
timber config --auto-hk-install
timber config --no-auto-hk-install

Enabling automatic trust permits mise configuration in eligible worktrees to execute code. Eligibility requires a root-level, regular mise.toml and a GitHub upstream owned by you or one of your organizations. Timber uses authenticated gh lookups and caches the identity for 24 hours in $XDG_DATA_HOME/timber/github-identity.json (fallback: ~/.local/share/timber/github-identity.json). Remove this cache after switching GitHub accounts or changing organization membership to force a fresh lookup. If Timber cannot refresh an invalid cache, it warns and skips trust without failing worktree creation. A failed mise trust also produces a warning and keeps the worktree.

Enabling automatic hk hook installation runs hk install in eligible worktrees. Installed hooks run repository-defined commands on Git events. Eligibility requires a root-level, regular hk.pkl and a GitHub upstream owned by you or one of your organizations. Timber adds --mise when the worktree also has a root-level, regular mise.toml, so hooks can use mise-managed tools. A failed hk install also produces a warning and keeps the worktree.

Per-worktree Git hooks

Linked worktrees share one hooks directory by default. create and switch give each worktree its own directory at <git-dir>/hooks so that hooks can differ per worktree. Timber copies the hooks that the worktree had before the split, and switch adds the setting to a worktree that an older Timber version created.

Timber sets core.hooksPath only when no scope sets it already. A hooks path from your Git configuration, from a hook manager such as hk, or from an earlier Timber run stays in control. A failure to configure hooks in switch exits non-zero, so the shell does not change directory.

Per-worktree configuration needs the extensions.worktreeConfig Git extension. Timber turns it on when it first configures hooks, and moves the bare repository marker into the main worktree configuration first, because Git stops special-casing core.bare once the extension is on. Git versions older than 2.20 reject this extension.

Entry points:

Commit review

timber review embeds the Go implementation from nnutter/git-review. It stores review state separately from the standalone tool. History from the standalone tool and earlier Timber builds is not migrated. Interactive review still requires tuicr on PATH.

timber review status --json
timber review log --oneline
timber review commits
timber review feedback --json

A review covers the stack patch and the commit message that tuicr shows. A rebase keeps a review because neither changes. An amend! or reword! fixup, or applying one, changes the message and asks for a new review.

timber review auto delegates review of Refactor: stacks to an AI model. An approval is recorded as model evidence and shows as Auto-Approved. An abstention is recorded and shows as Auto-Reviewed until the stack content or message changes. Each result is recorded as its stack completes, so a failed or interrupted run keeps finished work and the next run resumes with the stacks that have no result.

Configure the endpoint in $XDG_CONFIG_HOME/timber/model.json, a file that follows Pi's models schema:

{
  "$schema": "https://pi.dev/schemas/models.schema.json",
  "providers": {
    "openai": {
      "baseUrl": "https://api.openai.com/v1",
      "api": "openai-responses",
      "apiKey": "$OPENAI_API_KEY",
      "models": [{ "id": "gpt-5.2", "maxTokens": 16384 }]
    }
  }
}

A model must use api: "openai-responses". --model or TIMBER_REVIEW_MODEL selects provider/model or an exact model ID; a file with one model needs no selection. apiKey and headers values can be a literal, $NAME/${NAME} environment interpolation, or !security ... output, which keeps a secret out of the file. Timber runs only /usr/bin/security with the remaining words as literal arguments and never uses a shell, so !security find-generic-password -s my-service -w reads one keychain item. The flag beats the environment and the environment beats the file, so a missing file falls back to OPENAI_API_KEY and OPENAI_BASE_URL. --dry-run assesses without recording results.

The generated shell wrapper also supports t review …, including nested command and flag completion. Review commands operate on the current checkout, not a Timber worktree selector. Run timber review --help for all commands and flags.

Install the review feedback skill with timber review --install-skill. Use --force to replace an existing installation. For the Pi extension and package installation, see pi/README.md.

Version tags

timber bump creates the next semantic version tag for a worktree's repository and pushes it to origin.

timber bump --patch feature/login@timber
timber bump --patch @timber
timber bump --minor --pattern 'v1.*' feature/login@timber
timber bump --major --no-push feature/login@timber
timber bump --patch --release --no-open feature/login@timber

Exactly one of --major, --minor, or --patch is required. The command reads the highest strict vMAJOR.MINOR.PATCH tag in the repository, creates the next tag as an annotated tag on the selected commit, and pushes it to origin. Tags belong to the repository rather than to one checkout, so every worktree of a repository sees the same tags; the selector names the commit that the new tag points at.

  • <worktree>@<repo> tags that worktree's branch tip.
  • @<repo> tags the repository's default branch, so a release names the commit that origin serves and needs no checkout; the command works outside a Git directory.
  • No selector tags the worktree that contains the current directory.

Before it reads tags or creates one, the command fetches the remote of the tracked branch, and origin, so a tag created on another machine is in the candidate list. It refuses a worktree that is behind its upstream, because the new tag would name an outdated commit; update the worktree and run the command again. The default branch is fetched, so the check does not apply to @<repo>. A repository without a remote still bumps locally.

  • --pattern limits candidates the way git tag -l <pattern> does. Tags that are not strict semver are skipped. A pattern that matches no tag is an error, so a typo cannot invent a fresh version; without a pattern, a repository without a usable tag starts at v0.0.0.
  • --no-push leaves the new tag local.
  • --release creates a draft GitHub release for the new tag with authenticated gh and prints its URL after the tag. The URL is opened with open or xdg-open; --no-open skips that. A missing or failing browser warns and still reports the bump as successful.

The new tag is on stdout, so TAG=$(timber bump --patch feature/login@timber) captures it.

Entry points:

Agent skills

Install the bundled general-purpose Timber skill for agents with:

timber --install-skill
# To update an existing installation explicitly:
timber --install-skill --force

This writes skills/timber/SKILL.md to ~/.agents/skills/timber/SKILL.md, using the same installation layout as timber todo --install-skill for the separate timber-todo checklist skill. Existing skill files are preserved unless --force is given. No repository registration or GitHub authentication is needed to install a skill. Use a build containing this feature if an older binary does not recognize the flag.

The general skill covers repository discovery, explicitly qualified worktree creation, JSON status inspection, TODO lookup, and approval-gated cleanup. It requires Timber and Git on the agent runner's PATH; GitHub PR status also requires authenticated gh. It does not require the interactive zsh wrapper or Herdr. See the skill for configuration, side effects, and safety limitations.

Agents that discover ~/.agents/skills can load the installed timber skill. For agents with another skill directory, copy skills/timber into that agent's documented discovery location; the installer does not configure individual agents. Restart or refresh the agent's skill catalog if it caches discovery.

Herdr Plugin

Install the bundled Herdr plugin with:

timber herdr install

That command writes the plugin to ~/.config/herdr/plugins/timber ($XDG_CONFIG_HOME/herdr/plugins/timber when set) and runs herdr plugin link on the copy. Copying the files is not enough on its own: Herdr only registers actions after plugin link or plugin install. The popup runs timber tui --herdr --no-title after it adds common tool paths. The TUI can create a new worktree or open a Herdr space for an existing one. Worktree spaces open grouped under a parent space for the repository; the parent runs a Status tab that refreshes timber list @<repo> (with --pr when gh is authenticated). The command prints a keybinding snippet to add to ~/.config/herdr/config.toml:

[[keys.command]]
key = "prefix+shift+s"
type = "plugin_action"
command = "nnutter.timber.open"
description = "open or create Timber Space"

[[keys.command]]
key = "prefix+shift+d"
type = "plugin_action"
command = "nnutter.timber.delete"
description = "delete Timber worktree"

Development

This repository uses mise for tools and tasks.

mise install
mise run setup
mise run all

mise run all runs fixes, tests, and checks. mise run fix applies every configured fixer, including go fix and go mod tidy. mise run test runs the Go and plugin shell tests with coverage. Zsh completion tests also need zsh, python3, and PTY support; mise run ci-test requires these dependencies rather than silently skipping those tests. mise run check runs the configured checks, including gitleaks, and validates skills. mise run coverage runs the tests and prints coverage. mise run demo re-renders the demo GIF, MP4, and still from docs/demo/demo.tape (needs a working Chrome for VHS). mise run setup installs the hk Git hooks from hk.pkl without removing other hook managers. The commit-message hook requires timber on PATH.

Shell integration

Generate a zsh function that wraps the CLI (default name t):

timber generate zsh
# or: timber generate zsh --name t --out $XDG_DATA_HOME/zsh/site-functions --force

The default command generates the wrapper t, the completion _t, and the autoload helper _t_autoload. The helper starts with #autoload t, which lets compinit make t available without source or an explicit autoload command. With --name foo, the command generates foo, _foo, and _foo_autoload, and the helper starts with #autoload foo. Ensure the output directory is on fpath before zsh runs compinit, then restart zsh or run compinit.

The generated function:

  • routes most commands to timber (t create, t list, t prune, …)
  • after a successful t create, cds into the new worktree unless --no-cd, --herdr, or automatic Herdr workspace creation applies
  • provides a shell-only switch that cds into a worktree
  • t switch -c or t switch --create creates the worktree first, then cds, unless --no-cd or a Herdr space is opened
  • t switch <name> uses that worktree if the name exists in exactly one registered repository
  • t switch <Tab> completes worktree names from every registered repository
  • Unique names complete without @
  • If a name exists in more than one repository, completion offers <name>@<repo> for each one (artisinal@liaison, artisinal@persona)
  • after a successful t remove of the worktree the shell is in, or after a successful t repo import, cds to $HOME
t repo add nnutter/timber
t create feature/login@timber   # then cd into it
t create feature/custom@timber ../checkout # custom location, then cd
t create @timber                # random name, then cd into it
t switch feature/login@timber
t switch feature/login          # unique name across repositories
t switch -c feature/new@timber  # create then cd
t switch feature/new@timber -c  # same; -c can follow the name
t tui                           # open an existing worktree or create a new one
t herdr install                 # install the Herdr plugin
t herdr space                   # current worktree
t herdr space feature/login@timber
t create --no-cd other@timber   # create only
t remove feature/login          # cd $HOME only if you are inside it
t list
t list @timber

Commands

The following aliases are available for the commands below:

Command Alias
create c
hooks hk
list ls
prune clean
remove rm
repo rp
review r
todo td

The nested command groups have these aliases as well:

  • hooks commit-msg → hooks cm
  • pr create → pr c
  • repo list → repo ls
  • repo remove → repo rm
  • repo rename → repo mv
  • review approve → review a (review confirm also works)
  • review commits → review c (review run and review challenge also work)
  • review files → review f
  • review log → review l
  • review status → review s

Zsh completion expands an alias to the command it stands for: t rp<tab> becomes t repo with a trailing space. The expansion waits while a command name shares the typed prefix, so t r<tab> still lists review, repo, and remove.

Repository selection

Worktree commands take an optional <worktree>@<repo> qualifier on the name argument.

  • feature/login@timber selects that worktree in the timber repository
  • feature/login is enough when the name exists in exactly one registered repository
  • @timber selects the repository with no worktree name (create generates a random name; list and prune pin that repository)

list and prune use every registered repository unless @<repo> pins one.

create (and switch -c) use the current repository when the cwd is a managed worktree of a registered repo, otherwise an interactive picker. remove, move, herdr space, and pr create with no name target the managed worktree that contains the cwd. bump with no name targets the same worktree, and bump @<repo> targets the repository's default branch.

In non-interactive environments commands that need a single repository fail unless @<repo> is set or the cwd auto-detects a managed repo.

Worktree names must not contain @.

Command summary

For full flags and examples, see timber --help and timber <command> --help.

Command What it does
repo add Register a bare repository (GitHub owner/repo shorthand supported)
repo init Create a local repository with an empty initial commit (-b/--initial-branch, default main)
repo list List registered repositories with aliases and origin URLs
repo import Convert an existing clone into a managed repository
repo fix Move bare repositories from the old data directory into the worktree root, repair worktree links, and set push.default=current
repo remove Unregister a bare repository once its worktrees are gone
repo rename Rename a repository and move its worktree directories
config Show settings or change them with --auto-mise-trust / --no-auto-mise-trust / --auto-hk-install / --no-auto-hk-install
create Create a worktree for a branch (--no-herdr skips the Herdr space)
switch Shell-only: cd into a worktree, creating it first with -c
tui Interactively open an existing worktree or create a new one
herdr install Install the bundled Herdr plugin
herdr space Open a Herdr Agent + Shell space for a worktree
list List worktrees with merge/dirtiness status, TODO.md progress, and commit review progress (--json, --pr, --sort, -L/--location for checkout paths in the last column)
prune Remove clean, merged worktrees (--dry-run, --prompt)
remove Remove one worktree and delete its branch (--force overrides safety)
move Move a worktree checkout to a new path (name[@repo] <path>)
pr create Open a GitHub pull request for a pushed worktree branch (--title, --body, --base, --draft)
bump Create and push the next semantic version tag (--major/--minor/--patch, --pattern, --no-push, --release, --no-open)
hooks commit-msg Add a Gerrit-compatible Change-Id to a commit message (timber review commit-msg stays as a hidden alias)
todo Open the current worktree's TODO.md in $EDITOR
generate zsh Generate the t wrapper, completion, and autoload helper

Typical Flow

# once: install wrapper
timber generate zsh

# register a repo
t repo add nnutter/timber

# day to day
t create feature/login@timber
t switch feature/login@timber
# ... work ...
t switch main@timber   # if you created a main worktree
t prune @timber
# or:
t remove feature/login

About

I’m not context-switching. I’m worktree-chopping.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages