Why not submodules or subtree?
Submodules attach a foreign pointer to your repo and demand a second clone. Git subtree collapses upstream history into yours and gives you no clean way to refresh. This extension keeps a small declarative state and treats vendored content as your own files โ because once it lands in your tree, it belongs there.
| Concern | Submodules | git subtree |
git-third-party |
|---|---|---|---|
| Single clone, no extra indirection | โ | โ | โ |
| Visible to grep, IDE, build out of the box | โ | โ | โ |
| Lockfile pinning resolved upstream commits | โ | history-only | โ |
| Pull only a sub-path or filtered file set | โ | โ | โ |
| Recurse into upstream submodules | manual | โ | โ |
| Apply local patches across updates | โ | manual merge | โ |
| Single-command refresh of all entries | โ | โ | โ |
| CI gate that fails on stale vendoring | โ | โ | โ |
Compared to git-vendor
brettlangdon/git-vendor is the closest existing
tool: a Bash wrapper around git subtree that stores vendoring metadata in squash-merge commit
messages and follows a Go-style vendor/<host>/<owner>/<repo> layout. If
git subtree already meets your needs and you want a thinner UX on top, it works well.
Where git-third-party differs:
- Declarative config plus lockfile. State lives in
third-party.tomlandthird-party.lock, not in commit messages.update --checkbecomes a one-line CI gate. - Path filtering.
--subdir,--include,--excludelet you vendor only the files you actually use. - Submodule inlining. Upstream submodules get recursively materialized;
git subtreeleaves them as broken pointers. - Local patches. Saved tree-patches reapply via 3-way merge on every update.
- Bindings. Python and Node bindings share the same Go core.
git-vendoris shell-only.
Install
Three packages, one binary core. The wheel and the npm package each ship the platform-specific binary; building from source needs Go 1.21 or newer.
CLI
Pick whichever package manager you already have. Each distribution ships the same precompiled binary.
# uv tool is the recommended path:
$ uv tool install git-third-party
# pip works too:
$ pip install --user git-third-party
$ npm install -g git-third-party
$ pnpm add -g git-third-party
$ go build -o ~/.local/bin/git-third-party .
Python bindings
The Python wheel bundles a c-shared library, so install needs no Go compiler. Mutating calls accept
dry_run= and commit_msg=; every call accepts repo_path=, defaulting
to the current directory.
from git_third_party import init, add, list_, version
print(version())
init()
add(dir="vendor/foo", url="https://github.com/x/y", follow="main")
for e in list_():
print(e.dir, e.commit)
Errors map to GitThirdPartyError and its subclasses: ConfigError,
NetworkError, ConflictError, CheckDirtyError.
Node bindings
ECMAScript Modules (ESM) only, ships TypeScript types, requires Node 18 or newer. Same options shape as the
Python API; same error hierarchy. The bridge serializes calls process-wide โ for parallel work, use
worker_threads.
import { init, add, list, version } from "git-third-party";
console.log(version());
init();
add({ dir: "vendor/foo", url: "https://github.com/x/y", follow: "main" });
for (const e of list()) {
console.log(e.dir, e.commit);
}
The npm package distributes platform binaries through optionalDependencies, the same pattern
esbuild and swc use. Installs grab a prebuilt binary; no compiler needed.
Quick start
Vendor a directory that tracks an upstream branch, then pull updates and stage the diff. Three commands cover the whole loop.
Vendor a directory
$ git third-party add third_party/zlib https://github.com/madler/zlib --follow master
$ git commit -m "Vendor zlib"
Pull updates
# all entries:
$ git third-party update
# one entry:
$ git third-party update third_party/zlib
# dry-run โ would anything change?
$ git third-party status
$ git commit -m "Update vendored zlib"
List, rename, remove
$ git third-party list
$ git third-party rename third_party/zlib vendor/zlib
$ git third-party remove vendor/zlib
What update does
Tracking a ref
Each entry tracks one of: a moving branch tip, a pinned tag, or a commit SHA.
- --follow <branch>
- Track the branch tip and re-resolve on every
update. Default when neither flag is set (resolved from the remote'sHEAD). - --pin <tag-or-sha>
- Pin to a tag (resolved once and then cached) or to a commit SHA (used as-is). 40-hex-character strings count as SHAs.
Switch tracking mode with set:
# pin to a tag:
$ git third-party set vendor/zlib --pin v1.3.1
# back to a moving branch tip:
$ git third-party set vendor/zlib --follow master
Filtering content
Most upstream repos contain test fixtures, CI config, docs, examples, and build systems you don't need. Three flags keep only the files you use.
$ git third-party add vendor/foo https://example.com/foo.git \
--subdir src \
--include '*.c' --include '*.h' \
--exclude 'tests/'
- --subdir DIR
- Start from a subdirectory of the upstream repo. Other paths are dropped before include/exclude run.
- --include PATTERN · repeatable
- Keep only matching paths. Default keeps everything.
- --exclude PATTERN · repeatable
- Drop matching paths. Always wins over --include.
Patterns follow .gitignore rules with documented deviations: no trailing /**, no
. or .. segments. See git third-party add --help for the full
specification. Upstream submodules inline recursively unless excluded.
Configuration files
Two files live at the repository root; commit both alongside the vendored content.
- third-party.toml · hand-edited
- Hand-editable intent. Each
[[third_party]]table is one vendored directory. Eachadd/set/rename/removerewrites the file, dropping any user comments. - third-party.lock · generated
- Records the resolved commit and any saved tree-patch per entry. Sorted by
dirfor stable diffs. Do not edit by hand.
Example third-party.toml
# git third-party โ vendored content config.
[[third_party]]
dir = "third_party/zlib"
url = "https://github.com/madler/zlib"
follow = "master"
[[third_party]]
dir = "vendor/foo"
url = "https://example.com/foo.git"
pin = "v1.3.1"
subdir = "src"
include = ["*.c", "*.h"]
exclude = ["tests/"]
Corresponding third-party.lock
# git third-party lockfile โ generated; do not edit by hand.
version = 1
[[third_party]]
dir = "third_party/zlib"
commit = "abc1234567890abcdef1234567890abcdef12345"
[[third_party]]
dir = "vendor/foo"
commit = "def4567890abcdef1234567890abcdef12345678"
Command reference
The full CLI surface. The table lists aliases where they exist; every command is
also reachable as git third-party <verb>.
| Command | Aliases | Purpose |
|---|---|---|
init |
โ | Create an empty third-party.toml. Most users skip this โ add creates it. |
add DIR URL |
โ | Register a new entry and download it. |
set DIR โฆ |
edit |
Change URL, ref, or filters for an existing entry. |
unset DIR FIELDโฆ |
โ | Clear subdir, include, or exclude on an entry. |
update [DIR] |
up |
Re-fetch and stage updates. |
status [DIR] |
st |
update --dry-run. |
list [DIR] |
ls |
Show entries and their tracking mode. |
info DIR |
show |
Print full details for one entry. |
rename DIR NEW_DIR |
mv |
Move a vendored directory and update the config. |
remove DIR |
rm |
Unregister a directory and git rm -r its content. |
patch save DIR experimental |
โ | Record local edits as a tree-patch. |
patch diff DIR experimental |
โ | Show the saved tree-patch via git diff. |
completion SHELL |
โ | Print a bash/zsh/fish/powershell completion script. |
Common flags
- -v / -vv / -q · logging
- Debug, trace, or warn-only output. --log-level=trace|debug|info|warn|error sets the level explicitly.
- --log-format=text|json
- Switch the stderr handler to JSON for machine-readable logs.
- --color=auto|always|never
- Honors
NO_COLOR. - --dry-run
- Plan without staging.
- -f / --allow-dir-exists
- Let add and rename write into a non-empty target.
- --profile PATH
- Write a CPU profile.
- --json
- Emit a structured
entryResult(or array) on stdout instead of human text. Schema lives inoutput.go. - --commit MSG
- Run
git commit -m MSGafter the command stages changes. - --check · update / status
- Exit non-zero if any entry would change. Useful in CI and pre-commit hooks.
Exit codes
Stable for scripting. Wire status and update --check into CI.
| Code | Meaning |
|---|---|
| 0 | Success. |
| 1 | Generic failure. |
| 2 | Configuration invalid (TOML parse, validation, lockfile schema mismatch). |
| 3 | Network, fetch, or ref-resolution failure. |
| 4 | Unresolvable merge conflict during update. |
| 5 | --check detected a pending change. |
- name: Verify vendoring is up to date
run: git third-party update --check
Settings
Settings resolve through five layers, each overriding the previous: built-in defaults, per-user git config, the repository's [settings] table, environment variables, CLI flags.
- Built-in defaults.
- Per-user:
git config --global third-party.<key>. - Per-repo: a
[settings]table inthird-party.toml. - Environment variables.
- CLI flags.
Environment variables
- GIT_THIRD_PARTY_LOG_LEVEL
- Same as
--log-level: trace, debug, info, warn, error. - GIT_THIRD_PARTY_LOG_FORMAT
- Same as
--log-format: text or json. - GIT_THIRD_PARTY_COLOR
- Same as
--color: auto, always, never. - GIT_THIRD_PARTY_EXPERIMENTAL
- Comma-separated feature names; same as
--experimental. - NO_COLOR · cross-tool
- Standard convention; disables ANSI color even when
--color=auto.
Shell completions
Generate completion scripts for the major shells.
# bash
$ source <(git third-party completion bash)
# zsh
$ git third-party completion zsh > "${fpath[1]}/_git third-party"
# fish
$ git third-party completion fish > ~/.config/fish/completions/git-third-party.fish
Editing vendored content
Experimental
Record local modifications as a tree-level patch; update re-applies
it on every refresh.
If you need to modify vendored code โ a typo fix, a small portability tweak โ record it as a tree-level
patch. update re-applies the patch via 3-way merge. Conflicts get tagged with a
-conflicts suffix in the lockfile, keeping conflicts visible until resolved.
# enable the experimental feature once
$ git config --global third-party.experimental patch
# record local edits as a tree-patch
$ git third-party patch save vendor/foo
# inspect the saved patch via git diff
$ git third-party patch diff vendor/foo
If update produces conflicts, git-third-party stores the patch with a
-conflicts suffix; resolve with git add followed by another
patch save. Review the resulting commits carefully โ the feature is experimental for a reason.
FAQ
Common questions, short answers.
Yes. Vendored content lives directly in your tree โ no .gitmodules, no second clone,
no submodule pointer to forget.
git subtree merges upstream history into yours, which makes refreshing painful and
obscures provenance. git-third-party keeps a tiny declarative state โ intent plus lock
โ and rewrites the tree on update. Closer to a package manager than a Git operation.
Yes. --subdir starts from a subdirectory; --include and
--exclude filter individual paths.
git third-party update re-resolves the ref, fetches the new commit, applies your
filters, re-applies any saved local patches, and stages the result. You commit it.
Yes. --pin v1.3.1 or --pin <40-hex-sha>. Tags resolve once and
freeze; SHAs go through verbatim.
Yes. Authentication uses your existing Git configuration โ anything that works for
git fetch works here.
git third-party update --check exits non-zero (code 5) if any entry would change. Wire
it into a pre-commit hook or CI job to fail on stale vendoring.
Many monorepo automation systems use one or the other. Both bindings call into the same Go core through a cgo bridge, so behavior matches the CLI exactly.
git on PATH. The bindings also need CPython or Node 18 or newer. Every
distribution ships a precompiled binary, so runtime needs no Go compiler.
github.com/khwstolle/git-third-party. Issues and pull requests welcome.