inrepo
Bring upstream source into your repo without submodules, forks, or mystery patches.
inrepo is a small CLI for vendoring upstream git repositories directly into your project.
Use it when you want the ergonomics of local source code, but still want the discipline of pinned dependencies. Instead of hiding changes in node_modules, publishing a private package, or keeping a long-lived fork alive, inrepo gives you a repeatable recipe:
upstream git commit + your committed patches = generated local package
You edit the vendored code in inrepo_modules/, capture your changes into inrepo_patches/, and let teammates or CI rebuild the same tree with inrepo sync.
Sometimes the safest way to depend on upstream code is to make the exact code visible in your normal repo workflow.
Package registries are convenient, but they are also an attack surface. Compromised package publishes, suspicious dependency changes, and install-time scripts are becoming more common. When that happens, teams need to know exactly what code they installed, what changed, and how to get back to a reviewed version quickly.
inrepo is not a magic security boundary, and it does not replace lockfiles, audits, or incident response. What it gives you is a clearer operational model for packages you care about deeply:
- Pin the upstream git commit you reviewed.
- Keep local changes as reviewable files in pull requests.
- Rebuild generated code from a small recipe instead of trusting a mutable working tree.
- Run
inrepo verifyin CI to catch drift. - Depend on local
file:packages from your rootpackage.json.
That makes upstream code easier to inspect, patch, and reproduce when the package manager ecosystem gets noisy.
Run it in a project that wants to vendor upstream packages. inrepo requires Node.js 20+.
npx inrepo --helpPrefer inrepo permanently on your $PATH? Install via Homebrew on macOS or Linuxbrew:
brew tap inthhq/tap
brew install inrepo
inrepo --helpThe formula installs the same artifact that npm publishes, so npx inrepo and brew install inrepo are interchangeable. The rest of this README uses npx inrepo because it requires no install; substitute inrepo after brew install if you prefer.
Initialize config:
npx inrepo initAdd and pin a package:
npx inrepo add <package>If npm metadata does not point to the right GitHub repository, pass the git URL yourself:
npx inrepo add <package> --git https://github.com/owner/repo --ref mainTo vendor the package's runtime dependencies alongside it, add --with-deps:
npx inrepo add <package> --with-depsThen work like this:
npx inrepo sync
# edit files in inrepo_modules/<package>/
npx inrepo patch <package> -m "why this change"
npx inrepo diff <package>
git commitTeammates can reproduce the generated package with:
npx inrepo syncCI can check that nothing drifted:
npx inrepo verifyinrepo keeps a clean boundary between source inputs and generated output.
Commit these:
inrepo.jsonorpackage.json#inrepodeclares what to vendor.inrepo.lock.jsonpins each package to an exact upstream commit, and records the dependency graph when you vendor one.inrepo_patches/<package>/stores your team's edits and deletions.
Two patch formats are supported:
inrepo_patches/<package>/series/0001-*.patchis an ordered git patch series. Patches are standardgit format-patch --binaryoutput, applied in filename order withgit am --3wayon top of the pinned upstream commit. There is no separate series manifest; the file name is the order.inrepo_patches/<package>/whole-file snapshots plus.inrepo-deletionsare the original overlay format. They still work, and keep being used by any package that still has them.
New captures go into the patch series. A package only stays on the snapshot format while snapshot files are present; run npx inrepo migrate <package> to move it across.
Do not commit these:
inrepo_modules/<package>/is rebuilt byinrepo sync..inrepo/stores cache, state, and backups.
The package you add is wired into your root package.json as a local file:inrepo_modules/<package> dependency. Graph-managed transitive instances are reached through their recorded dependency edges instead of becoming host dependency keys. Use npx inrepo add <package> -D or "dev": true in config when the root should land in devDependencies.
Prefer inrepo.json at the project root:
{
"packages": [
{
"name": "example-package",
"git": "https://github.com/owner/repo",
"repositoryDirectory": "packages/example-package",
"ref": "main",
"dev": false,
"keep": ["src", "package.json"],
"exclude": ["test", "/\\.snap$/"]
}
],
"keep": ["LICENSE"],
"exclude": [".github"]
}You can also put the same object under package.json#inrepo.
nameis the npm package/source name. It is also the destination for packages added directly.moduleis an optional storage identity under config, lockfile,inrepo_modules/, andinrepo_patches/;--with-depsgenerates version-qualified values for transitive instances.gitis optional when npm metadata can resolve the GitHub repository.repositoryDirectoryselects the package root inside a monorepo. npm'srepository.directoryis discovered automatically; use--repository-directory <path>with a manual--gitsource.refcan be a branch, tag, or commit before the lockfile resolves the exact commit.devchoosesdevDependenciesinstead ofdependencies.keepallowlists paths before exclusions run.excluderemoves literal relative paths or slash-delimited regex matches.
inrepo tries not to silently destroy local work.
During sync, it compares the current generated module and overlay against recorded state. If inrepo_modules/ changed but the overlay did not, it treats that as uncaptured work and asks you to run npx inrepo patch. If both changed, it reports a conflict. npx inrepo sync --force can discard generated edits, but saves a backup under .inrepo/backups/. If you installed the CLI globally, the same command is inrepo sync --force.
Patch capture is guarded too. inrepo patch refuses to run when the overlay changed behind your back, and tells you to sync first.
inrepo patch <package> -m "reason" compares inrepo_modules/<package> against the patched tree — the pinned upstream commit plus the patches already in the series — and appends whatever you changed as the next numbered patch:
npx inrepo patch <package> -m "Replace the event emitter for static compilation"Every invocation writes a new patch; there is no amend or squash. The -m text becomes the patch subject, so the message is required. When nothing changed, the command says so and writes no patch.
Patch headers are the provenance record. From:, Date:, and Subject: capture who made the change, when, and why, so no separate manifest is needed. The author comes from your git user.name and user.email.
Packages that still carry snapshot files keep the original capture behavior: inrepo patch <package> rewrites the whole-file overlay and records deleted files in .inrepo-deletions.
inrepo diff shows the effective delta between the pinned upstream commit and the patched tree, so a review sees hunks instead of whole replacement files:
npx inrepo diff <package> # unified diff, plus the patch series that produced it
npx inrepo diff <package> --stat # per-file +/- summary
npx inrepo diff # every vendored packageThe diff is rendered by git, so deletions, mode changes, symlinks, and binary files all read correctly. Packages on the snapshot format are covered too, including their .inrepo-deletions entries. inrepo diff is a viewer: it exits 0 whether or not there are differences, and only fails on an unknown or unvendored package.
inrepo update <package> re-resolves the pinned ref, rebases the committed patch series onto the new upstream commit, and rebuilds everything:
npx inrepo update <package> # follow the configured ref to its current tip
npx inrepo update <package> --ref v2.1.0 # move to another branch, tag, or commitThe rebase runs in a scratch git repository, so upstream changes to a patched file are merged instead of hidden. When it succeeds, inrepo rewrites the series (renumbered from 0001, with every patch's original subject, author, and date preserved), updates inrepo.lock.json, saves a --ref back to your config, and re-syncs inrepo_modules/<package>. A patch upstream has since adopted itself is dropped. Nothing is written until the rebase finishes, so a failed update leaves the repository exactly as it was.
Packages with no patches are simply re-pinned and rebuilt. Packages still on the snapshot format cannot be rebased; run npx inrepo migrate <package> first.
When a patch and upstream touch the same lines, the update stops and reports the patch that failed and the conflicted files. The in-progress rebase is kept in .inrepo/updates/<package>/repo, an ordinary git work tree with ordinary conflict markers:
<<<<<<< HEAD
export const v = 3;
=======
export const v = 42;
>>>>>>> Bump the exported version
Edit those files in place — there is no need to git add anything — then:
npx inrepo update <package> --continue # finish the rebase and move the pin
npx inrepo update <package> --abort # throw the update away, changing nothing--continue picks up where git stopped and repeats the report if a later patch conflicts too. If your resolution leaves a patch with nothing to apply, that patch is dropped from the series. Until an update finishes, inrepo_patches/, your config, the lockfile, and inrepo_modules/ are untouched, and starting another update for the same package tells you to finish or abandon this one first.
inrepo add <package> vendors exactly one package, so its imports of other packages still resolve through node_modules. Add --with-deps to vendor the whole runtime dependency tree as visible source instead:
npx inrepo add <package> --with-depsFor registry-resolved roots, inrepo reads the exact published dependencies (including npm's rewrites of workspace ranges), resolves each range to an exact published version, maps that version to an immutable repository commit, and recurses. It prefers npm's publish-time gitHead, then a matching release tag, and can fall back to a source commit bound to the published tarball by npm provenance. A manual --git root remains authoritative and uses its checkout manifest. Only runtime dependencies are followed — devDependencies, peerDependencies, and optionalDependencies are not added automatically.
The resolved tree is printed before anything is written:
commander 12.1.0 (a1b2c3d)
├─ picocolors ^1.0.0 → 1.1.1 (9f3e21c)
└─ shared ^2.0.0 → 2.4.0 (77c0b8a)
└─ picocolors ^1.0.0 → 1.1.1 (9f3e21c) (deduped)
Every resolved package is then vendored like a package you added by hand, but graph-managed dependencies have a versioned module identity. For example, citty@0.1.6 and citty@0.2.2 are separate config and lock entries, materialized at inrepo_modules/citty@0.1.6 and inrepo_modules/citty@0.2.2. Each graph edge still uses the bare dependency name and points it at the exact module instance selected for that dependent. The root keeps its normal package name. A compatible instance is reused rather than re-pinned, and running --with-deps again on a package you already vendored completes the missing part of its graph.
Registry dependencies also retain the selected version's npm tarball URL and integrity. During materialization, inrepo verifies and caches that payload, then fills only files absent from the git checkout. This restores publish-only runtime output such as dist/ without replacing repository source or package.json; git files always win. The payload is a generated, integrity-pinned base input, so unchanged published files do not appear in inrepo diff or captured patches. Manual --git roots never acquire registry artifacts implicitly.
Scoped instances retain their npm layout: @scope/pkg@1.2.3 is materialized at inrepo_modules/@scope/pkg@1.2.3. The generated config entry keeps name: "@scope/pkg" as the import identity and records module: "@scope/pkg@1.2.3" as its storage identity. sync, verify, diff, patch, import rewiring, and patch paths use that module identity consistently.
When npm metadata declares repository.directory, the selected subtree becomes the module root: filters, patches, diffs, updates, and import rewiring all use package-relative paths. When that metadata is missing, registry dependency resolution scans the immutable checkout for a unique package.json matching the published package name and version (or a unique name match) and records the discovered directory. Packages at the same repository commit share one unfiltered repository snapshot while retaining separate filtered module trees. A strict npm owner/repo repository shorthand is normalized as GitHub metadata too.
The edges themselves are recorded under graph in inrepo.lock.json. Published artifact inputs raise the lock to lockfileVersion: 5:
{
"lockfileVersion": 5,
"modules": {
"picocolors@1.1.1": {
"source": "picocolors",
"gitUrl": "https://github.com/alexeyraspopov/picocolors.git",
"commit": "9f3e21c…",
"ref": "9f3e21c…",
"artifact": {
"tarballUrl": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-…"
},
"updatedAt": "…"
}
},
"graph": {
"commander": {
"version": "12.1.0",
"root": true,
"dependencies": {
"picocolors": {
"range": "^1.0.0",
"version": "1.1.1",
"module": "picocolors@1.1.1"
}
}
},
"picocolors@1.1.1": { "version": "1.1.1" }
}
}Because every dependency entry pins an exact git URL, immutable commit, and—when used—published integrity, inrepo sync and inrepo verify replay and check the whole graph from committed files with no registry access after the caches have been populated. Lockfile versions 1–4 remain readable: version 1 is the original module map, version 2 adds a graph, version 3 records repository subdirectories, and version 4 identifies module instances separately from their npm source names. Version 5 records published runtime artifacts so older clients fail safely instead of silently producing incomplete trees.
inrepo update <package> keeps a graph root in step with the pin it moves: the root's recorded version and the resolved version on every edge pointing at it are re-read from the rebuilt checkout, so inrepo verify stays clean. A versioned, graph-managed module instance cannot be updated directly; rerun add --with-deps for the graph root so its ranges are resolved together.
Resolution fails — before a single package is vendored — when:
- no published version satisfies one dependency range.
- a dependency uses a source
inrepocannot pin:workspace:,file:,link:,catalog:,npm:aliases, git URLs, tarball URLs, or a dist-tag. - a selected repository directory is missing or declares a different package, or automatic directory discovery is missing or ambiguous.
- a dependency has no usable
repositoryURL on the registry, or npm metadata, release tags, and cross-checked registry-hosted provenance provide no immutable source commit.
In every case the message names the dependency and the reason. A private or manually supplied monorepo package can be selected with npx inrepo add <dep> --git <url> --repository-directory <path> --ref <ref>.
Compatible ranges reuse the same versioned module instance; incompatible ranges resolve to separate instances instead of conflicting.
--with-deps cannot be combined with --no-save, since a graph is only replayable from committed config and lockfile entries.
Vendoring the graph does not, on its own, make it self-contained: the source still says import pc from "picocolors", which only resolves through node_modules. Turn on import rewiring to point those specifiers at the sibling checkouts instead:
{
"rewireImports": true,
"packages": [{ "name": "commander" }, { "name": "picocolors" }]
}inrepo_modules/commander/lib/help.js then reads:
import pc from "../../picocolors/picocolors.js";The setting is off by default, so existing projects are unchanged. Set it at the root to cover every package, or per package to opt one in or out:
{
"rewireImports": true,
"packages": [{ "name": "commander", "rewireImports": false }]
}What gets rewritten, and what does not:
- Only bare specifiers naming a package that the recorded
graphlists as a runtime dependency of the importing package. A specifier for anything else — a package you did not vendor,node:builtins, relative paths,#aliases, URLs — is left alone. import,export … from,import(…), andrequire(…), in.js,.mjs,.cjs,.ts,.mts, and.ctsfiles. Specifiers are located with a JavaScript lexer, not a text search, so a package name inside a string, comment, template literal, or regular expression is never touched.- Subpaths keep their shape:
pkg/sub/thing.jsbecomes a relative path to that file insideinrepo_modules/pkg. - A bare package name resolves to a concrete file — the dependency's
exports,module, ormainentry, honoringimportandrequireconditions — because Node's ESM resolver does no directory ormainlookup for relative specifiers.import "../picocolors"would fail whereimport "../picocolors/picocolors.js"works. - A specifier that names a vendored dependency but resolves to no file in it (a subpath that does not exist, say) is reported as a warning and left exactly as upstream wrote it, so the generated tree stays reproducible either way.
Rewiring is a generated transform, applied after the patch series, and it never enters the patch surface:
inrepo diffrenders the patched tree, so it never shows a rewritten specifier.inrepo patch <package> -m "…"computes the same rewrites against the patched tree and undoes them before comparing, so a captured patch contains your edit and nothing else — even when you edited the lines next to a rewired import.inrepo verifyreapplies the transform and compares, so a correctly rewired checkout passes and a hand-edited specifier is reported as drift.inrepo syncandinrepo updatereapply it every time, from committed files only. Rewiring the same tree twice changes nothing.
Because a rewritten specifier points into a dependency's checkout, sync vendors dependencies before the packages that need them, and reports what it rewrote:
Synced "commander" @ a1b2c3d → …/inrepo_modules/commander
Rewired 3 import specifiers in 2 files of "commander"
Convert a package's snapshot overlay into a git patch series:
npx inrepo migrate <package>This replays the current overlay over the pinned upstream commit, records the result as inrepo_patches/<package>/series/0001-*.patch, and removes the snapshot files only after confirming that applying the series reproduces the identical tree. If it does not, the overlay is left exactly as it was and the command reports why. Empty directories are the one thing a series cannot carry over, because git has no way to record them; the command lists any it had to drop.
From a clone of this repository:
bun install
bun run build
node dist/cli.mjs --helpexamples/scriptcis the controlled performance benchmark: identical CLI behavior with registry-backed npm dependencies in dynamic scriptc versus patched upstream source in static scriptc.examples/c15t-clicontains both the narrow selected-renderer scriptc microbenchmark and a separate full-entry case study. The latter executes the real@c15t/cli@2.2.0source with its 188-module inrepo closure, proves four published-CLI parity paths withoutnode_modules, and reports the full source/cache size cost. It records scriptc's failed full compile instead of publishing a misleading static timing.
- Overview
- Quickstart
- Config reference
- CLI usage:
npx inrepo --help
- Open an issue on the GitHub repository
- Visit inth.com
- We're open to community contributions.
- Fork the repository
- Create a new branch for your feature or fix
- Submit a pull request
- All contributions, big or small, are welcome and appreciated.
If you believe you have found a security vulnerability in inrepo, we encourage you to responsibly disclose this and NOT open a public issue. We will investigate all legitimate reports.
Our preference is that you make use of GitHub's private vulnerability reporting feature. To do this, please visit https://github.com/inthhq/inrepo/security and click the "Report a vulnerability" button.
- Please do not share security vulnerabilities in public forums, issues, or pull requests
- Provide detailed information about the potential vulnerability
- Allow reasonable time for us to address the issue before any public disclosure
- We are committed to addressing security concerns promptly and transparently
Built by Inth