git-worktreeinclude safely applies ignored files listed in .worktreeinclude from a source worktree to the current worktree.
go build -o git-worktreeinclude ./cmd/git-worktreeincludeTo use the Git extension form (git worktreeinclude ...), place git-worktreeinclude on your PATH.
Subcommands are explicit. Use git-worktreeinclude apply ....
git-worktreeinclude applyOr via Git extension:
git worktreeinclude apply- Place
.worktreeincludeat the source worktree root (by default, this is typically the main worktree selected by--from auto). - Format is gitignore-compatible (
#comments, blank lines,!negation,/anchors,**, etc.). .worktreeincludemay be tracked, untracked, or ignored; if the file exists in the source worktree, it is used.
Each pattern is evaluated independently:
- Literal paths (no
*,?,[) act on the named source path directly. The path may be untracked, gitignored, or even untracked-but-not-gitignored — if it exists in the source worktree, it is a candidate. - Globs expand against the source worktree using
git ls-files, so untracked-but-not-gitignored files matching the glob are still picked up. - Negation lines (
!foo) subtract from the matched set with the usual gitignore semantics.
For each candidate, git-worktreeinclude performs a per-leaf tracked-check as it walks the source subtree:
- A tracked source leaf is silently skipped — no action, no conflict, no error. Listing a directory whose subtree mixes tracked and untracked content (
.claude/with committed shared rules plus untracked local agent files) is well-formed: tracked content stays untouched, untracked content is symlinked or copied. - A tracked target leaf is a hard
conflictwith statustracked.--forcedoes not override; refusing to clobber version-controlled content at the target is non-negotiable. - For
symlinkpatterns the walker anchors at the deepest fully-untracked subtree:.claude/skills/loquibecomes one directory symlink,.claude/skills/effect(tracked) is silently skipped. Where the subtree contains tracked content on either side, the walker descends and decides per-leaf.
Negation lines (!foo) are evaluated at candidate-resolution time and do not propagate into a walked subtree. Use a more specific pattern (or copy/symlink distinction) when fine-grained exclusion inside a directory matters.
Example for a typical app repo with local env, editor settings, and tool-specific cache:
.env
.env.local
!.env.example
.vscode/settings.json
.mise.local.toml
turbo/.cache/Each line may carry trailing whitespace-separated attributes after the glob:
<glob> [attr ...]
Recognized attributes:
copy(default): the matched path is byte-copied into the target worktree.symlink: the target gets a symlink pointing at the absolute path of the source file in the source worktree.submodule-walk: only valid alongsidesymlink, and only on a literal (non-glob) pattern. Recurses into the source submodule's working tree and emits per-leaf actions inside the target's existing mountpoint instead of replacing the mountpoint with one directory-level symlink. Combiningsubmodule-walkwithcopy(default or explicit) is rejected at parse time with the errorsubmodule-walk requires symlink mode; pairing it with a glob is rejected withsubmodule-walk requires a literal pattern. See Submodules for the full contract.
If both copy and symlink appear on the same line, symlink wins. Unknown attributes (e.g. binary, eol=lf, export-ignore, key=value) are accepted and ignored, so this file format stays forward-compatible and can sit next to (or share content with) .gitattributes-style metadata.
Example:
# Default: copy
.env
.env.local
# Share large directories instead of duplicating per worktree
node_modules symlink
.cache/ symlink
# Forward-compat tokens (no effect today)
package-lock.json binary
tests/fixtures/** export-ignoreSymlink mode notes:
- The link target is an absolute path to the source file. If the source worktree is later moved or removed, the link will dangle.
- Pre-existing destinations:
- already a symlink to the same source: skipped as
same link - already a symlink to a different target: conflict;
--forcereplaces - already a regular file: conflict;
--forcereplaces - already a directory: conflict
- already a symlink to the same source: skipped as
- A pattern resolved as
copywill also conflict if the destination already exists as a symlink (matches symlink-mode strictness;--forcereplaces with a regular file). - Globs cannot contain whitespace (no quoting): the first whitespace run separates pattern from attributes.
- "Any matching
symlinkpattern wins": if a path matches both a copy-mode pattern and a symlink-mode pattern, it is symlinked. Negation lines (!foo) are honored within the symlink set the same way they are in a regular gitignore file.
When a symlink-attributed pattern resolves to a fully-untracked directory in the source worktree, git-worktreeinclude creates one symlink at the destination pointing at the source directory's absolute path. Use this to share large untracked trees (caches, build outputs, vendored deps) across worktrees without duplicating bytes.
When the same pattern resolves to a partial-tracked directory — tracked content on either side, untracked content beside it — the walker descends and anchors at the deepest fully-untracked subtree it can reach. Tracked source leaves are silently skipped; tracked target leaves emit conflict/tracked. Submodule paths (gitlinks) and source-side symlinks are handled at every depth, not only at the pattern root.
When the same pattern resolves to a directory under the default copy mode, the directory is always walked and each contained file is copied individually (no dir-anchor shortcut).
Submodules listed in .gitmodules (gitlink entries in the index) bypass the tracked-check, so they can be referenced from .worktreeinclude:
- With
symlink: a directory-level symlink is created at the destination pointing at the source submodule's working tree. This is the default behavior and the only mode that materializes the submodule with a single action. - With
copy(the default when no attribute is given): the action is skipped with statussubmodule_copy_unsupported. Submodule content is not byte-copied; symlink the submodule path instead. - With
symlink submodule-walk: the walker shares the source submodule's entire working tree (sans.git) with target, emitting actions inside the target's existing mountpoint rather than anchoring a single dir-level symlink at the mountpoint. Use this when target already needs a real directory at the mountpoint (e.g. it carries local-only files alongside the submodule contents). Specifics:- Requires
symlinkmode.copy submodule-walkand baresubmodule-walk(default-copy) are parser errors (submodule-walk requires symlink mode). - Requires a literal (non-glob) pattern. Globs like
vendor/* symlink submodule-walkare rejected at parse time (submodule-walk requires a literal pattern) because glob expansion usesgit ls-files, which does not see content inside submodules. - Top-level entries inside the submodule WT anchor as single dir-symlinks where target's path is absent; recursion only happens when target already has real content at the same path. Submodule-tracked content is shared the same way as untracked content — both live only in source's initialised submodule WT, so target needs access to both.
- Target-side tracked content at the same leaf path produces
conflict/tracked(rare in practice — the mountpoint is typically empty aftergit checkout). Target-side tracked-checks use the parent repo's index because the mountpoint sits in the parent worktree. - The submodule's
.gitgitdir-pointer file at the submodule root is silently skipped — no leaf, no skip, no conflict, no error. As a consequence,git submodule statusin the target reports the submodule as uninitialised, andgit submodule update --initin the target is not expected to do anything meaningful when the mountpoint is already populated bysubmodule-walk. This is the explicit tradeoff: the target gets the submodule's content without becoming a second submodule checkout. - The mountpoint itself stays a real directory; it is never replaced with a symlink. Per-leaf and per-subdir symlinks land inside it.
- Like the regular walker,
.worktreeincludenegation lines (!foo) are evaluated at candidate-resolution time and do not propagate into the submodule walk.
- Requires
If a matched source path is itself a symlink, it is recreated at the destination as a symlink, regardless of any copy/symlink attribute on the pattern. The same rule applies at the pattern root and at every depth of a walked subtree (including submodule-walk):
- A source-side symlink whose target resolves inside the source worktree — whether the original target is written as a relative path (
.codex -> .claude) or an absolute path (/abs/source/.codex -> /abs/source/.claude) — is rewritten at the destination to that source-absolute path. The recreated link in the target worktree resolves through to source's canonical content (e.g. target's.codexreads as/abs/source/.claude), which always exists regardless of how the target worktree was assembled. This makes the dotfile-alias pattern (.codex -> .claude, meaning "treat .codex as an alias for the canonical .claude") work uniformly across worktrees. - A source-side symlink whose target resolves outside the source worktree (relative or absolute) is preserved verbatim. Cross-boundary links are the user's responsibility — if the external target moves or disappears, the link will dangle.
Conflict, same-link, dry-run, and --force semantics for recreated source-symlinks match symlink mode above.
Print the installed version.
git-worktreeinclude --version
git-worktreeinclude -vUses the current worktree as target and copies from source worktree.
git-worktreeinclude apply [--from auto|<path>] [--include <path>] [--dry-run] [--force] [--json] [--quiet] [--verbose]--from:auto(default) chooses the first non-bare worktree fromgit worktree list --porcelain -zother than the current (target) worktree (typically the main worktree). When the target is the only non-bare worktree,applyis a no-op success (exit code0); pass--from <path>explicitly to force a specific source.--include: include file path (default:.worktreeinclude)- relative path: resolved from source worktree root only
- absolute path: must be inside source worktree root
--dry-run: plan only, make no changes- use
--dry-run --verbosewhen you want diagnostics about source/target selection, include file resolution, and planned actions - in dry-run mode, the human-readable summary uses
copy_planned=/symlink_planned=instead ofcopied=/symlinked=, and the JSON summary uses"copy_planned"/"symlink_planned"instead of"copied"/"symlinked"
- use
--force: overwrite differing target files--json: emit a single JSON object to stdout--quiet: suppress human-readable output--verbose: print additional details
Safe defaults:
- Never touches tracked files
- Never deletes files
- Never overwrites by default (differences become conflicts, exit code
3) - Missing source
.worktreeincludeis a no-op success (exit code0) - Single-worktree clone with
--from autois a no-op success (exit code0)
apply --json emits a single JSON object to stdout.
Normal execution (apply --json):
{
"dry_run": false,
"from": "/abs/path/source",
"to": "/abs/path/target",
"include_file": ".worktreeinclude",
"summary": {
"matched": 12,
"copied": 8,
"symlinked": 1,
"skipped_same": 3,
"skipped_missing_src": 1,
"skipped_submodule_copy": 0,
"conflicts": 0,
"errors": 0
},
"actions": [
{"op": "copy", "path": ".env", "status": "done"},
{"op": "symlink", "path": "node_modules", "status": "done"},
{"op": "skip", "path": ".mise.local.toml", "status": "same"},
{"op": "conflict", "path": ".vscode/settings.json", "status": "diff"}
]
}Dry-run mode (apply --dry-run --json):
{
"dry_run": true,
"from": "/abs/path/source",
"to": "/abs/path/target",
"include_file": ".worktreeinclude",
"summary": {
"matched": 12,
"copy_planned": 8,
"symlink_planned": 1,
"skipped_same": 3,
"skipped_missing_src": 1,
"skipped_submodule_copy": 0,
"conflicts": 0,
"errors": 0
},
"actions": [
{"op": "copy", "path": ".env", "status": "planned"},
{"op": "symlink", "path": "node_modules", "status": "planned"},
{"op": "skip", "path": ".mise.local.toml", "status": "same"},
{"op": "conflict", "path": ".vscode/settings.json", "status": "diff"}
]
}"dry_run": trueindicates no files were written- In dry-run mode
"copy_planned"/"symlink_planned"are used instead of"copied"/"symlinked"in the summary (they are mutually exclusive) opis one ofcopy,symlink,skip,conflict,expandstatusforskipincludessame,same_link,missing_src,submodule_copy_unsupported(incrementsskipped_submodule_copy),errorstatusforconflictisdiff(copy mode),diff_link(symlink mode), ortracked(target has tracked content;--forcedoes not override)op:expandwithstatus:walkedis a per-pattern rollup emitted only when a directory candidate triggered recursion. The action carries anexpandedinteger counting every per-leaf action emitted under that walk (includingsame_link,done,conflict,skip). The rollup itself does NOT incrementsummary.matched—summary.symlinked,summary.copied, and the rest count leaves only.pathis repo-root relative and slash-separated- File contents and secrets are never output
A walked partial-tracked directory looks like this in actions:
[
{"op": "symlink", "path": ".claude/agents", "status": "done"},
{"op": "symlink", "path": ".claude/skills/loqui", "status": "done"},
{"op": "expand", "path": ".claude", "status": "walked", "expanded": 2}
]Run this immediately after creating a worktree:
git worktree add <path> -b <branch>
git -C <path> worktreeinclude apply --json- Evaluate success by exit code
- Use JSON
summaryandactionsfor details
0: success1: internal error2: argument/usage error3: conflict (apply) or unknown help topic4: environment/prerequisite error
not inside a git repository: run from a Git repositorysource and target are not from the same repository: verify--frompoints to the same repo worktreesource and target are the same worktree:--fromresolved to the current worktree; pass--from <path>to a different worktree- single-worktree clone:
--from autois a no-op success; create another worktree or pass--from <path>if you want an actual copy - conflict exit: use
--forceor resolve target differences first - no-op due to missing include: verify
.worktreeincludeexists in the source worktree selected by--from - if include exists only in target: copy that file to source worktree (or run with a different
--from)
make fmt
make check-fmt
make vet
make lint
make test
make test-race
make ciCI runs on pull requests and pushes to main via GitHub Actions.
golangci-lint is used with its default configuration (no .golangci.yml).
make lint installs a pinned golangci-lint binary into .cache/bin on first run, so the first run needs network access to fetch the tool.
MIT. See LICENSE.