Skip to content

v0.3.0

Choose a tag to compare

@timvw-ci-bot timvw-ci-bot released this 21 Aug 06:11
· 3 commits to main since this release
11bdcc9

Two commits since v0.2.0, both features: worktrees can now materialise the untracked files they need, and [[context]] rules can live in your global git config instead of the config file.

Features

  • Built-in file copy (#144, #148) — a [files] section materialises the untracked files a new worktree needs (.env, .envrc, local settings), with reflink/CoW where the filesystem offers it:

    [files]
    copy = [".env", ".envrc", ".claude/settings.local.json"]  # gitignore syntax
    link = ["node_modules"]                                    # symlink instead
    exclude = ["*.pem"]                                        # applied last, non-overridable
    copy_ignored = false                                       # or: copy every ignored file

    Runs automatically on wt create, wt checkout, wt pr and wt mr, after git worktree add and before the post_* hooks — including when the worktree already exists, so one made before [files] was configured catches up. Never fatal: a failure is reported and the worktree survives.

    A .worktreeinclude at the main-worktree root is unioned into copy, in gitignore syntax, for repos that would rather commit the list than a .wt.toml. The list keys accumulate across layers (config file → repo .wt.toml.worktreeinclude), deduped first-seen; copy_ignored follows the ordinary scalar precedence chain, spelled wt.copyIgnored in git config.

    wt copy [branch] does the same on demand, with --dry-run, --force and --from; --no-copy skips it once and WT_FILES_DISABLED=1 turns it off entirely. Copies use clonefile(2) on APFS, FICLONE on Btrfs/XFS and copy_file_range(2) where available, through a bounded worker pool, falling back to a buffered copy — which is what turns "copy a few dotfiles" into "clone a multi-gigabyte build cache". wt info gains a Files section, wt config show reports copy_ignored, and every command supports --format json.

    Why this needs no wt trust, unlike hooks. [files] cannot execute anything. It names paths inside two worktrees, and every path is checked against seven invariants, each with a test in cmd/files_security_test.go: source patterns resolve strictly inside the main worktree (F1); destination paths strictly inside the new one (F2); .. segments are rejected at config-load time, gitignore escapes included (F3); symlinks are copied as symlinks, never dereferenced (F4); files git tracks are never copied, in either worktree, by construction (F5); an existing destination is never overwritten without --force (F6); nothing outside the two worktrees and os.TempDir() is read or written (F7). Candidates come from git ls-files --others --ignored --exclude-standard, so a tracked file is never a candidate to begin with, and gitignore semantics are git's rather than a reimplementation. VCS directories and registered worktrees of the same repo are excluded unconditionally.

    copy_ignored defaults to false. Copying every ignored file is a deliberate opt-in: silently moving gigabytes, or a .env.production, into a new worktree is not a default worth having.

    Edge cases worth knowing about: a path the destination branch tracks is never written, --force included — including when it is absent there (deleted, or a sparse checkout), and including a tracked ancestor or subtree. Case-insensitive destinations (APFS, NTFS, and also ext4 casefold or CIFS — probed, not assumed) report two source paths differing only in case as a collision rather than merging them, directories included, settled before anything is created. --dry-run predicts exactly what the real run does, down to which paths a copy claims before a link can have them. No-clobber is TOCTOU-free (O_CREAT|O_EXCL); --force replaces atomically via a temp file and rename.

  • [[context]] rules from global git config (#146, #147) — a follow-up to #145, which shipped [[context]] rules with the config file as their only source. Rules can now live in ~/.gitconfig:

    git config --global wt.context.work.whenpath "~/dev/repos/work"
    git config --global --add wt.context.work.env "WT_CATEGORY=work"
    git config --global --add wt.context.work.env "WT_ORG=acme"

    wt.context.<name>.<key> is a git subsection, so <name> is a handle that makes the rule removable with git config --global --remove-section 'wt.context.work'.

    Git config rules are evaluated first, then the config file's, under the same "later definitions win per variable" rule that already governs rules within a single source — so the two compose rather than one replacing the other. Wholesale replacement would have matched how scalars behave, but it makes adding one unrelated [[context]] block to config.toml silently delete every rule in ~/.gitconfig.

    Global only. Not --local: a path rule scoped to a single repository is redundant, since that repository could set wt.pattern directly, and .git/config is shared by every worktree of a repo. Not --system either, for a duller reason — wt reads no system git config for any setting, and making wt.context.* obey a scope that wt.root ignores would be a worse inconsistency than the gap.

    Internally, gitConfigFn now returns []gitConfigEntry in the order git listed them rather than a last-wins map, because a rule sets env once per variable with --add. Scalar settings are unchanged — they collapse to last-wins as before.

Docs

  • docs/configuration.md gains a Files section (patterns, precedence, .worktreeinclude, case-insensitive destinations, dry-run output) and documents wt.copyIgnored alongside the other git-config keys.
  • docs/examples.md gains copy recipes.

Full commit list: v0.2.0...v0.3.0