git-cross is a small tool for pulling part of one Git repository into another with git worktree plus rsync.
It is for the cases where you want real files in your tree, not a gitlink, and want a simple way to refresh them from upstream.
git-cross is useful when you want to:
- vendor only one subdirectory from another repository
- keep vendored code as normal files in your own repository
- pull upstream updates without converting your repo into a submodule setup
- manually compare local edits with the sparse upstream worktree when both have changed
- let AI tools work on a mounted subfolder instead of your full repository
Compared with submodules or subrepo-style flows, the tradeoff is simple: git-cross keeps files physical and visible in your repo, and tracks the upstream relationship separately through hidden worktrees and Crossfile.
- Pull
docs/from an upstream project intovendor/docs - Keep a shared app template in
vendor/templateand customize it locally - Work on an upstream library inside
vendor/libwith normal editor tooling - Let an AI sandbox mount only
vendor/libinstead of the whole repo - Publish your local combined result to your own
origin
The repository supports two implementations:
- Go: primary production implementation
- Just/Fish: readable reference implementation
For normal usage, prefer the Go binary.
just installManual build:
cd src-go
go build -o git-cross-go main.go
git config --global alias.cross "!$(pwd)/git-cross-go"just install shell# 1. Register upstream
git cross use demo https://github.com/example/demo.git
# 2. Materialize one directory locally
git cross patch demo:docs vendor/docs
# 3. Inspect local state
git cross status
git cross diff vendor/docs
# 4. Pull new upstream changes later
git cross update vendor/docsgit-cross connects four things:
- Your main repository
- A hidden worktree for each upstream patch under
.cross/worktrees/ - A local physical directory such as
vendor/docs - A
Crossfilethat records how to rebuild the setup
On disk:
your-repo/
├── Crossfile # declarative use/patch records
├── .cross/
│ ├── metadata.json # patch registry (worktree, branch, paths)
│ └── worktrees/
│ └── <remote>_<hash>/ # sparse git worktree; contains a .git pointer FILE
│ └── <remote_path>/ # checked-out upstream subtree
└── vendor/docs/ # rsynced files; no .git at all
Worktrees live in the repository tree under .cross/ (gitignored), and the
local directory holds plain rsynced files with no .git of its own.
In practice:
- your editor sees normal files
- AI tools can work on normal files
- upstream history stays in the hidden worktree
Crossfilerecords theuseandpatchdeclarations;git cross updatereads them to refresh patches
git-cross enables Git's rerere ("reuse recorded resolution") so that
conflict resolutions survive across updates. This is what solves resolving the
same update conflict twice:
git cross initturnsrerere.enabledon and tells you so.git cross patchenables it silently.git cross updatere-enables it when it was turned off, so repositories created before this feature self-heal.
When upstream changes collide with your local copy, resolve the conflict once —
typically inside the sparse worktree, after entering it with
git cross wt <path> and merging or rebasing there. Git records that
resolution, and every later update that hits the same conflict replays the
recorded resolution instead of asking you again: the next merge or rebase over
the same upstream hunk is resolved without a second manual edit.
Notes:
-
The setting lives in the repository's shared Git config, so it applies to the main repository and to every
git-crossworktree. -
Recorded resolutions are local to each worktree (its
rr-cache), so every patch keeps its own conflict memory. -
rerere.autoUpdateis deliberately not enabled. Replayed resolutions are applied to the working tree but not staged, so a merge still stops and waits forgit add+git commit(withoutrerere.autoUpdate). To let Git stage what it resolved for you, opt in:git config rerere.autoUpdate true -
git cross updateitself uses--ff-onlyand therefore never conflicts; the recorded resolutions matter in the manual workflow where you merge or rebase upstream inside the worktree.
One common pattern is to keep local-only files next to upstream-managed files.
Example:
git cross use app https://github.com/example/app.git
git cross patch app:src vendor/app
cat > vendor/app/.crossignore <<'EOF'
.env
compose.override.yaml
EOFRule of thumb:
- a plain basename entry such as
.envmatches that name in any subdirectory under the patch - a directory entry such as
configorconfig/matches that directory tree - a basename glob such as
*.envalso matches anywhere under the patch - this is intentionally simpler than full
.gitignoresemantics
What this does today:
git cross statusshowsOverridefor that patchgit cross diff vendor/appprints manualgit diff --no-index ...commands for the override paths- your local files stay in place for normal local work
This is intentionally conservative. .crossignore entries are review signals, not a substitute for review before updating.
If you keep secrets or machine-local files in a patched directory, review them explicitly before updating.
There are two separate publish flows.
This is just normal Git in your main repository:
git add vendor/app
git commit -m "Update vendored app and local overlays"
git push origin mainYour own repository stores the final combined result, including local overlay files if you choose to commit them there.
When you want to contribute upstream-managed changes back:
cd "$(git cross wt vendor/app)" # or: crosswt vendor/app (see shell integration)
git cross compare vendor/appRecommended pattern:
- Enter the sparse worktree with
git cross wt vendor/app(orcrosswt vendor/app). - Compare it with the local patch using
git cross compare vendor/app, or print a console diff withgit cross diff vendor/app. - Manually copy desired changes into the worktree, then commit and push with Git.
compare uses the command configured in cross.dirdifftool; see Directory Diff Tool.
This lets your main repo keep its local opinionated result while the upstream contribution stays focused.
- Overview
- Tutorial: Local Overlays And Upstream Contribution
- Tutorial: Whole Upstream Into A Local Directory
- Tutorial: Migrate A Private Fork To git-cross
- Tutorial: Split One Repo Into A Clean Upstream And A Private Overlay
- Sandbox Kits
git cross --help groups commands by intent, starting with the two you need
first — use, then patch:
Patch Lifecycle:
use Add a remote repository
patch Create a patch from a remote path
remove Remove a patch
prune Remove a remote and all its patches
Repository:
init Create an empty Crossfile
completion Print shell completions and cd wrappers to evaluate
list Show all configured patches and remotes
status Show patch status
update Update patches from Crossfile declarations
compare Open the configured directory comparison tool for a patch
Inspect & Navigate:
cd Print a patch local_path to cd into
wt Print a patch worktree to cd into
diff Show changes between local and upstream
compare also answers to the short alias git cross cmp.
Running git cross with no subcommand does not print help. It prints one row
per configured patch to stdout:
$ git cross
vendor/app .cross/worktrees/app_1a2b3c4d
vendor/lib .cross/worktrees/lib_5e6f7a8b
The listing is the machine-readable row format used throughout the tool:
when stdout is a terminal the first column is space-padded for readability;
when piped, rows stay local_path<TAB>worktree so
git cross 2>/dev/null | cut -f1 yields local paths. Warnings and error
hints go to stderr. Use git cross -h or git cross --help for the grouped
help shown above.
git cross use <name> <url>Register an upstream remote alias and detect its default branch.
git cross patch <remote>:<path> [local_dest]Create or reuse a hidden worktree, sparse-check out the upstream path, and sync it into your local directory.
git cross update [path]Read Crossfile, fetch upstream updates, and update each patch.
If the local copy still matches the previously installed upstream content, update refreshes it in place. If the local copy has edits, update preserves it, advances the sparse worktree to the new upstream state, and prints only the fact:
==> WARNING: 'vendor/docs' differs from upstream; local copy preserved
After the run, a generic review block follows (both commands use fzf or the current patch when no path is given):
Review the differences:
git cross diff # pick a patch with fzf and see the diff
git cross compare # pick a patch and update it (dirdiff tool)
Review the differences and merge manually. Whole-repository patches (remote:. and remote:/) take part in this check correctly: the worktree's .git pointer file is not counted as a local edit, so a clean whole-repo patch is updated instead of warned about.
Resolutions you record manually are remembered for later updates — see Conflict Resolution Memory (rerere).
git cross compare [path]
git cross cmp [path]Open the configured directory comparison tool between one patch's local copy and its sparse worktree.
compare works on exactly one patch. With no path argument it resolves the patch the same way diff does: CWD auto-detect first, then an fzf selection. If no patch can be resolved (including when fzf is missing), it prints the available patches as rows, exits with status 1, and does not dump a usage block.
git cross diff [path]Print a unified diff between local files and the hidden worktree copy on the console.
With no path argument, the patch is resolved in this order:
- CWD auto-detect — if the current directory is inside a patch's
local_path, that patch is used (and an auto-detect note is printed). fzfselection — otherwise, iffzfis installed, pick a patch interactively.- Rows + hint — if
fzfis not installed, print the available patch rows plus a hint tocdinto a patch directory or pass a path, and exit 1.
Cancelling the fzf selection (Esc) exits 0 without doing anything.
Behavior change: diff with no path from outside a patch directory used to dump diffs for all patches. It is now interactive.
Both commands view the same pair of directories — the local copy and the sparse worktree — but they target different workflows:
diff |
compare |
|
|---|---|---|
| Output | unified diff on stdout | the tool from cross.dirdifftool |
| Scope | one patch (from a path, CWD, or fzf) |
exactly one patch (from a path, CWD, or fzf) |
| Unresolved patch | rows + exit 1 | rows + exit 1 |
| Typical use | quick review, scripting, CI | side-by-side editing in an editor |
.crossignore overrides are still review signals: status shows Override, and diff prints manual git diff --no-index ... commands for the override paths instead of a full diff.
git cross list [--raw] [--no-header]Show configured remotes and patches. Default output is a table with
Configured Remotes: / Configured Patches: banners and descriptive column
headers.
--raw switches to machine-readable output: no table borders and no banners.
When stdout is a terminal, columns are space-padded so they line up; when
piped, rows are plain TAB-separated values. --no-header omits the header
line in both table and raw mode. In raw mode the section order is fixed — all
remotes first, then all patches:
REMOTE_NAME REMOTE_URL
REMOTE REMOTE_PATH LOCAL_PATH WORKTREE BRANCH
Each remote with a different fetch and push URL produces one row per URL; both
rows stay in the two-column schema, so the URL field is never decorated with
(fetch) / (push) suffixes. Remote rows are sorted by name. In pipes, header
rows are themselves tab-separated, so use --no-header in pipelines.
Parsing examples:
# Local paths only (patch rows have 5 fields, remote rows have 2)
git cross list --raw --no-header | awk -F'\t' 'NF == 5 { print $3 }'
# Worktree paths
git cross list --raw --no-header | awk -F'\t' 'NF == 5 { print $4 }'
# "<remote>:<branch>" for every patch
git cross list --raw --no-header | awk -F'\t' 'NF == 5 { print $1 ":" $5 }'
# Remote URLs
git cross list --raw --no-header | awk -F'\t' 'NF == 2 { print $2 }'A plain cut works on the patch rows, but leaves blank lines for the
two-column remote rows:
git cross list --raw --no-header | cut -f3 | sed '/^$/d'When nothing is configured, raw mode writes the empty-state message to stderr so stdout stays a clean pipe.
git cross statusShow local diff state, upstream divergence, and conflicts.
git cross remove <path>Remove one patch and clean up local state.
git cross prune [remote]Remove unused remotes, stale worktrees, or all patches for one remote.
git cross cd [path]
git cross wt [path]Print the absolute destination path for one patch: cd targets the local copy
(local_path), wt targets the hidden sparse worktree.
The path is the only thing written to stdout; hints and errors go to stderr.
Run directly, the command only prints a path: a child process cannot change
your shell, so cd "$(git cross cd)" would change a throwaway sub-shell only.
A child process cannot change the working directory of the shell that started
it — no CLI can. Tools that appear to do it (yazi, zoxide, autojump) all use
the same pattern: the program prints the destination, and a small shell
function captures it and runs cd in your shell. git cross completion
prints that function pair together with the completion script:
# fish (~/.config/fish/config.fish, or a file in ~/.config/fish/functions/)
git cross completion fish | source
# shell auto-detected from $SHELL:
git cross completion | source# bash / zsh (~/.bashrc, ~/.zshrc)
eval "$(git cross completion)"
# or force a shell:
eval "$(git cross completion bash)"That defines two functions, which take the same optional path (and fzf
selection) as the commands themselves:
crosscd vendor/app # cd into the local copy
crosswt vendor/app # cd into the sparse worktree
crosswt # no path: fzf selectiongit cross init prints the matching one-liner for your $SHELL when it
creates a Crossfile.
Prefer to install the wrappers by hand? Copy the function into your shell's function directory (fish) or profile (bash/zsh); they are plain wrappers, not special builtins:
# ~/.config/fish/functions/crosswt.fish
function crosswt --description 'cd into a git-cross worktree'
set -l p (git cross wt $argv)
or return
test -n "$p"; and cd $p
end# ~/.bashrc / ~/.zshrc
crosswt() { local p; p="$(git cross wt "$@")" || return; [ -n "$p" ] && cd "$p"; }
crosscd() { local p; p="$(git cross cd "$@")" || return; [ -n "$p" ] && cd "$p"; }Short aliases work as ordinary aliases; for example gccd/gcwt behave like
cd into the local copy/worktree:
alias gcwt crosswt
alias gccd crosscdalias gcwt=crosswt
alias gccd=crosscdKeeping stdout path-only is deliberate: it makes the output composable in
scripts (dir=$(git cross cd vendor/app)) and safe to capture in a function.
With an explicit path the patch is resolved directly. Without one, fzf is
used to select a patch. The picker puts LOCAL_PATH first, then
REMOTE:REMOTE_PATH, and appends the branch in parentheses only when it is not
main; columns are padded so long remote names cannot run into the next field:
vendor/mxcsrc mxc-library:src
vendor/mxcpkg mxc-library:pkg (feature)
vendor/docs-src docs:src
3/3
──────────────────────────────────────────────
LOCAL_PATH REMOTE:REMOTE_PATH
Select patch>
If fzf is not installed, the command prints the available patch rows plus a
hint to cd manually, and exits 1.
compare uses the command configured in cross.dirdifftool. Configure it
globally or per repository:
git config --global cross.dirdifftool '<command>'The key is named after the directory diff tool it configures (DirDiff and
similar tools compare whole trees of files). The pre-0.4 key cross.diffTool
is still read as a deprecated fallback; migrate with:
git config --global cross.dirdifftool "$(git config --global --get cross.diffTool)" \
&& git config --global --unset cross.diffToolIf the command contains {left} or {right}, both placeholders are replaced
before the command is run:
{left}— the patch's path inside the sparse worktree{right}— the patch's local copy
The substituted paths are repo-relative, and the command runs with the
repository root as its working directory. When cross.dirdifftool is unset the
built-in default is used — nvim if it is installed, otherwise vim:
nvim "+let g:DirDiffExcludes = '.git'" "+DirDiff {left} {right}"
vim "+let g:DirDiffExcludes = '.git'" "+DirDiff {left} {right}"
Note the two separate +cmd arguments: DirDiffIgnore=... is not a vim
command, so the exclusion is set with let first, then DirDiff runs with the
directories as arguments. To pin a specific editor, set it explicitly:
git config --global cross.dirdifftool "nvim \"+let g:DirDiffExcludes = '.git'\" \"+DirDiff {left} {right}\""
# strictly vim:
git config --global cross.dirdifftool "vim \"+let g:DirDiffExcludes = '.git'\" \"+DirDiff {left} {right}\""A custom ignore list passes DirDiff a comma-separated list of names to skip:
git config --global cross.dirdifftool "nvim \"+let g:DirDiffExcludes = '.git,ves.io,f5fed.com,volterra.us'\" \"+DirDiff {left} {right}\""Two optional variables feed the built-in default; both are comma-separated:
| Variable | Effect |
|---|---|
DIRDIFF_EXCLUDES |
Extra file/dir names to hide. .git is always excluded (permanent prefix). |
DIRDIFF_IGNORE |
Keywords ignored inside the diff content — useful when the upstream is e.g. github.com and your copy is gitlab.com. |
export DIRDIFF_EXCLUDES='vendor,node_modules,dist'
export DIRDIFF_IGNORE='github.com,gitlab.com'
git cross compare app # -> nvim "+let g:DirDiffExcludes = '.git,vendor,node_modules,dist'"
# "+let g:DirDiffIgnore = '\Vgithub.com\|gitlab.com'" "+DirDiff ..."DIRDIFF_IGNORE is converted to a vim very-nomagic regexp (\V), so dots
and other characters match literally; items are joined with \| (alternation).
Unset or empty variables simply omit the corresponding +let argument.
In this form the paths are part of the vim + command string, which is where
DirDiff reads them. The repository default already ignores .git, so the
worktree's .git pointer file never appears in the comparison.
A command without {left} / {right} keeps the old behavior: the two absolute
paths are appended as trailing arguments.
# Deprecated: paths become vim file arguments, not DirDiff arguments.
git config --global cross.dirdifftool 'vim "+DirDiff"'This form is deprecated because it does not invoke DirDiff correctly. Migrate to the template form above.
removeandprunenow guard against deleting repo root contents when a patch targets.- whole-upstream patching with
remote:.andremote:/works for vendoring into local directories - repository-root local targets are not supported by
updateyet - override markers in
.crossignorecurrently affect review surfaces:statusanddiff - if you are experimenting with root-target overlay workflows, treat them as advanced usage and verify results carefully
This repo already includes agent guidance for AI coding tools:
- shared repo roles in
.agents/ - Claude-facing entrypoints in
.claude/ - OpenCode-native skills in
.opencode/skills/
Current shared roles:
- maintainer
- feature spec writer
- safety reviewer
- regression tester
If you want an AI agent to work only on a vendored subtree, mount just that subtree in a sandbox and keep the main .git state on the host.
This repo includes repo-local starter kits under sbx-kits/.
Examples:
sbx run opencode --kit ./sbx-kits/opencode
sbx run claude --kit ./sbx-kits/claudeWhy use a kit:
- safer trial path for people who do not want to install a new tool globally first
- reproducible sandbox setup checked into the repository
- easy place to add sandbox-local agent config later
See sbx-kits/README.md for the structure, starter kits, and intended workflow.
High level:
- Just/Fish is the readable behavior reference
- Go is the main production CLI
- Rust source remains in the repository but is not maintained or supported
Detailed architecture notes live in docs/overview.md.
MIT