Sync individual files across many projects through a canonical git repo — version, push, pull, and merge single files that you copy around and occasionally edit, so they gradually converge on one stable version instead of drifting apart.
It's "git meets npm for one file": each tracked copy carries a one-line provenance header, the canonical history lives in a normal git repo, and merges are real 3-way merges (git does the merging — filesync is a thin layer on top).
You have a utils.ts / Makefile / tailwind.config.js that lives in ten projects. You tweak it per project, but you want those tweaks to flow back so one version slowly hardens. Publishing to a registry makes that pull-only and ceremony-heavy; copying by hand loses history and clobbers. filesync keeps the file where it is, records where it came from in a header, and lets changes move in both directions with a proper merge base.
Requires Go and git on PATH.
make build # -> ./filesync (small static binary)
make install # -> $GOBIN (or ~/go/bin)
make check # vet + testsThe only dependency is github.com/BurntSushi/toml (pure Go, no transitive deps).
# point filesync at a base repo (any git remote)
filesync init -g git@github.com:you/filesync-canonical.git
# in project A: publish a file (creates the canonical slot, stamps a header)
filesync push utils.ts
# in project B: pull the file in (fetches it the first time, merges after)
filesync pull utils.ts
# later, after either side changes it:
filesync pull utils.ts # fetch / merge / or offer to adopt, depending on state
filesync push utils.ts # send your changes up (3-way merge if it moved)After the first contact, the file carries a header and push/pull are automatic:
// @sync utils.ts sha=a1b2c3d
export function parse(x: string) { ... }The header records the canonical path and the commit you forked from — that's the only state, and it rides along inside the file so a copied file stays self-describing.
| Command | What it does |
|---|---|
init -g <remote> |
Set the global base repo to a remote. |
init <remote> |
Write filesync.config.toml so this project syncs to <remote>. |
push [-c ctx] <file> [slot] |
Send the local copy up (creates the slot if new). |
pull [-c ctx] <file> [dest] |
The read path — fetch if no local file, merge if tracked, or offer to adopt if an untracked copy. |
adopt [-c ctx] <file> [slot] [--base <sha>] |
Explicitly stamp a header onto a pre-existing drifted copy. |
plan <slot> <copies...> |
Advise which copy to seed from (read-only); add --run to walk through convergence one copy at a time (or --yes to auto-drive). |
config [--init] |
Show the config path, or scaffold a default config.toml. |
--yes/-y takes the affirmative branch of any prompt non-interactively (for scripts and CI).
If the same file already exists, edited differently, across several projects, there's no shared ancestor to merge against. plan converges them. Point it at the canonical slot and every copy:
filesync plan utils.ts ../proj-*/src/utils.tsBy default — and whenever it can't resolve a repo — plan is read-only: it ranks the copies and recommends a seed, writing nothing. Pass --run and it becomes an interactive driver that walks you through convergence one copy at a time:
- It picks (or lets you pick) the seed — the copy that establishes canonical — and pushes it.
- Each round, it recomputes how similar every remaining copy is to the current canonical, recommends the next one, and lets you choose it, skip, or stop.
- It converges that copy by running the real
adopt/push/pull— so every merge, conflict, and guard behaves exactly as those commands do. - On a conflict it pauses: the markers are written into the copy, and you resolve them and continue.
The driver keeps no state of its own. Each step re-reads every copy's header and compares it to canonical HEAD, so converged copies are skipped and a copy mid-conflict is detected and held. That means re-running the identical plan command resumes exactly where you left off — pressing "continue" in the interactive loop and re-running by hand are the same code path. Resolve a conflict, run the same command again, and it picks up at the next copy.
Ranking: the seed is chosen by last-modified time (a proxy for "closest to the ancestor") and centrality (smallest total diff to the others); when those disagree — e.g. the oldest copy is a heavy outlier — it recommends the most central and tells you why. Each subsequent round recommends the copy most similar to the current canonical (the easiest merge), but shows every copy's percentage so you can pick a divergent one first if you'd rather surface conflicts early.
For scripts and CI, --yes drives the whole thing non-interactively, taking the recommendation at every step.
Mark parts of a file that shouldn't sync like the rest. Markers are shared (they travel to canonical and act as anchors) but are recognized in any comment style, so a misdetected comment prefix can never silently skip them. Two families:
Full mask — content is project-local, never pushed, never merged, never in conflict:
# @filesync disable
API_SECRET=local-only-value
# @filesync enable
# @filesync ignore
NEXT_LINE_IS_LOCAL=tooCanonical stores just the empty markers as a template; each project fills in its own values.
Local-wins — content lives in canonical and merges normally, but on a conflicting line the local side wins silently (names propagate across projects, values stay per-project):
# @filesync local-start
github_account = you
# @filesync local-end
# @filesync local-line
NEXT_LINE_LOCAL_WINS=hereRedaction (on a local-wins block) keeps the keys shared but replaces values with a placeholder in canonical, so real values never leave your machine — and, as a bonus, lets newly-added keys propagate even when they sit next to a key you've overridden:
# @filesync local-start =
github_account = you # canonical stores: github_account = xxxxx
github_repo = your-repo
# @filesync local-end
# @filesync local-start r/\s=\s/ # regex delimiter instead of a literal
key = secret
# @filesync local-endA file's repo is resolved by location, never stored in the header (so cross-repo names can't leak into a codebase):
- The nearest
filesync.config.tomlwalking up from the file (a single self-containedremote = "..."). - Otherwise the global base repo (
init -g). - With neither, there is no repo and the command errors.
filesync.config.toml is safe to commit — it can only ever name its own one repo. Each repo is a local clone under $XDG_DATA_HOME/filesync/repos/<slug>-<hash>, one per remote, shared by every project that points at that remote.
Promotion. On pull, if a file's history isn't in the project repo but is in base, filesync asks (default no) whether to hard-copy it into the project repo. Saying yes re-homes the file there permanently — all future syncs go to the project repo and it stops syncing with base. push never falls back across repos; it errors instead.
A context is a namespace within one repo — a path prefix — for files that share a basename but aren't the same logical file (e.g. a Deno Makefile and a Solid Makefile):
filesync push -c deno Makefile # -> deno/Makefile in the repo
filesync push -c solid Makefile # -> solid/Makefile, a separate slotThe context is stored in the header (// @sync context=deno Makefile sha=...) and is part of the file's identity; the two never sync to each other.
filesync refuses silent clobbers at first contact:
- Untracked copy on pull.
pullon a local file with no header, where a same-named slot exists in canonical, reports how similar the two are and asks whether to adopt it (default no). Yes binds the file to the slot; no leaves it untouched (use-cif it's a different file, orpushto publish it as new). - Occupied slot on push. An untracked
pushinto a slot that already holds a file stops, reports similarity, and defaults to no — recommending-c <context>if it's actually a different file. - Promotion on pull. Pulling a base file into a project repo is an explicit, one-way choice, defaulting to no.
Once a file has a header, its slot is unambiguous and these guards don't apply.
The header and markers use the comment syntax for the file's type. Sensible defaults are built in (//, #, --, ;;, <!-- -->, /* */), shebangs and XML/PHP prologs are kept on line 1, and you can override or extend per extension:
filesync config --init # scaffold $XDG_CONFIG_HOME/filesync/config.toml[comments]
vue = { prefix = "<!--", suffix = "-->" }
toml = { prefix = "#" }- The header is stripped before content goes to canonical and re-added on the way back, so the
sha=line never participates in a merge. push/pullperform a true 3-way merge viagit merge-file, using the header's sha to recover the merge base. Genuine conflicts surface as standard markers and abort the push; local-region conflicts resolve automatically.- Canonical never receives full-mask content or real redacted values, even on the degraded fallback path.
- Interactive driving happens only with
--run(walk through one step at a time) or--yes(auto-drive for scripts); without either,planjust advises and writes nothing. Every prompt is EOF-safe, so a--runin a non-interactive context stops cleanly rather than looping.
- Adjacency in local-wins (non-redacted): a key newly added on the canonical side, immediately next to a line you've locally overridden, gets bundled into one conflict hunk and follows local-wins — so it may not propagate that pull. Use redaction (
local-start =) when you want reliable name propagation forkey = valueblocks. - Redaction key-matching is by the text before the delimiter, so reformatting whitespace around a key can miss the match — keep keys stable.
- Line comments are assumed for the marker family unless a block style is configured; extremely unusual comment syntaxes may need a config entry.
make build # build ./filesync
make test # go test ./...
make check # vet + test
make fmt # go fmt ./...Single Go file (main.go) plus tests (main_test.go); no codegen, no frameworks.