Syncing Single Files Across Repos Without Ceremony
Shared files drift across repos. npm is too heavy and copying by hand makes a mess. filesync closes the gap with git merges, a one-line provenance header, and private-first regions.
We all have that one utils.ts or Makefile or generic config that lives in ten different repositories. You tweak it a little for each project, and it drifts. Eventually you want those tweaks to flow back, so one version slowly hardens into something solid, whether for everyone else or just for future you.
But how do you sync a file safely across repos without losing history?
Publishing to an npm registry makes that pull-only and heavy on ceremony. Copying by hand clobbers changes and loses the git trail entirely.
I liked the idea of bit.dev for sharing components years ago, but the overhead it enforced was too much to justify. And that project has since pivoted full speed into AI, no thanks. I've also used the shadcn CLI a bit and liked how it could just drop a single file into your project for you.
The Rule of Three
When I'm deciding whether a file (or one of those functions I always reach for in a new repo) is solid enough to justify the ceremony of a full-blown npm package, I think back to a developer and friend I used to work with, Yusuke. I really respect his instincts, and his rule went something like this: before you turn something into a library, you should have reached for it in at least three projects. Once it's stable enough that you no longer have to tweak it for edge cases every time you pull it into a new project, it's finally ready to become something real that you document.
I've used that as a rule of thumb ever since, and it's stopped me from throwing up a pile of one-off libraries.
But what if you want to keep something private for personal use? Or you don't want the ceremony of building out a full project and npm-linking it every time you want to touch it? Or what if it isn't even something that belongs on npm at all?
I want to move files between my repos quickly and constantly, and I want them to be eventually consistent, but I want to edit my local copy first, so if a change breaks something else, I can opt into that update instead of being surprised by it.
I couldn't find anything that fit, and I couldn't justify writing it myself, because I was busy hand-writing the code I actually share. I still believe code should be written without AI assistance, and that it doesn't belong in my editor. But this was something for me to use personally, so why not?
So I opened the Claude web UI, gave it a long list of exactly what I wanted, and it became filesync.
I vibe-coded filesync purely for myself, as a single binary with zero transitive dependencies.
Lightweight by Design
You point it at a git remote to initialize, and it clones that remote into a local cache. From there you add individual files with a simple push or pull, and it stamps a single comment line at the top of each file that records the commit it was forked from, which git uses as the base for a 3-way merge.
And that's it. It leans on git as much as it possibly can: git does the merging, and every push commits a new version to the canonical repo.
It's completely stateless and deterministic: everything it needs lives in that one comment line at the top of the file.
// @sync utils.ts sha=a1b2c3d
export function parse(x: string) { ... }
That line is the only state. It records the canonical path and the commit you forked from, and it rides along inside the file, so a copy you paste into a new repo is still self-describing. There's no lockfile, no .filesync directory, no hidden database tracking what's what. Delete the header and the file is just a file again.
What if I have multiple files with the same name?
This was the first thing that bugged me. I have a Makefile in my Deno projects and a Makefile in my Solid projects, and they are absolutely not the same file. If they both pushed to a slot called Makefile, they'd stomp all over each other.
So filesync has "contexts." But a context isn't some clever tagging system. It's just a folder. The canonical repo is a normal git repo with files in it, and a context is a path prefix inside it:
filesync push -c deno Makefile # -> deno/Makefile
filesync push -c solid Makefile # -> solid/Makefile
Two slots, two folders, and they never merge into each other. The context gets written into the header so the file remembers who it is:
# @sync context=deno Makefile sha=a1b2c3d
I kept going back and forth on whether this needed to be something smarter, and every time the answer was no. It's folders. You already understand folders. That's the whole feature.
If you decide a file actually belongs in a different context later, you just push it with a new -c and it re-slots. The old slot is left alone, because some other project might still be tracking it. I really didn't want a "move" command that quietly deletes something out from under another repo. If nothing's using the old slot anymore, unsync clears it.
What if I want to reuse the logic but have secrets in my Makefile?
This is the one that almost killed the whole idea. The entire point is to share the logic of a Makefile across projects, but half of mine have a deploy target with a per-project image tag, or an env line with something I very much do not want landing in a shared git repo.
So filesync has local regions. There are two flavors, depending on how much of the line you want to keep private.
Full mask: the content is project-local, full stop. Never pushed, never merged, never even eligible to show up as a conflict:
# @filesync disable
API_SECRET=local-only-value
# @filesync enable
# @filesync ignore
NEXT_LINE_IS_LOCAL=too
Canonical only ever sees the empty markers, like a template. Every project fills in its own values between them, and none of those values leave your machine.
Local-wins with redaction: this is the one I actually reach for most. Sometimes I want the key to be shared, so that adding a new config option in one project propagates it everywhere, but I want the value to stay per-project. You give local-start a delimiter and it splits on it:
# @filesync local-start =
github_account = you # canonical stores: github_account = xxxxx
github_repo = your-repo
# @filesync local-end
Everything before the = (the key) syncs; everything after gets replaced with a placeholder in canonical, so the real value never leaves. If a plain = is too blunt, you can hand it a regex delimiter instead:
# @filesync local-start r/\s=\s/
key = secret
# @filesync local-end
The part I was most paranoid about: these markers are recognized in any comment style, not just the one filesync guessed for the file type. So if it ever misdetects the comment prefix on some weird file, it still can't silently skip a disable block and leak the thing you were trying to protect. Given how I feel about silent, destructive behavior in tooling, that one felt non-negotiable.
filesync was built after I already had the problem
Here's the awkward part. I didn't build this and then start using it cleanly from day one. I built it because I already had ten drifted copies of the same file scattered across projects, each edited a little differently, with no shared ancestor anywhere. And if there's no shared ancestor, there's nothing for a 3-way merge to stand on.
That's what plan is for. You point it at the canonical slot and every copy:
filesync plan utils.ts ../proj-*/src/utils.ts
By default it's read-only. It ranks all the copies, tells you which one to seed canonical from, and writes absolutely nothing to disk. It picks the seed from a mix of last-modified time (a rough proxy for "closest to the original") and centrality: the copy with the smallest total diff to all the others. When those two signals disagree, like when the oldest copy is also a weird outlier, it recommends the central one and tells you why.
Add --run and it turns into an interactive driver. It seeds canonical from the copy you pick, then each round it recomputes how close every remaining copy is to the current canonical, recommends the easiest next merge, and lets you take it, skip it, or stop. And it isn't faking any of this: it runs the real adopt/push/pull underneath, so a conflict behaves exactly like a normal conflict: the markers get written into the file, you resolve them by hand, and you continue.
The part I'm proudest of is that the driver keeps no state of its own. Every step just re-reads each copy's header and compares it to canonical HEAD. Converged copies get skipped; a copy sitting mid-conflict gets detected and held. Which means resuming is free: re-running the exact same plan --run command picks up right where you left off. Resolve a conflict, run the same command again, and it moves on to the next copy. Pressing "continue" in the loop and re-running by hand are literally the same code path. (--yes drives the whole thing start to finish for CI.)
You mentioned you don't vibe code, but this is vibe-coded?
I think it makes sense to vibe-code this one, since it's an internal tool I only ever intended to use myself. But since it's open source, I figure it's fine to let other people know about it too, with the understanding that you're using it at your own risk. I'm openly warning you: it was not built for client consumption, and I take no responsibility for how badly it might behave. I also had it add tests, but who knows how good those actually are. I haven't cared enough yet to really dig in, and Go seems simple enough that it can almost convincingly write it.
What about sharing with my teammates?
Everything above uses your global default repo, the one you set once with filesync init -g <remote>. I use mine for personal stuff. But a file's repo is resolved by location, never stored in the header, and that's what makes teams work.
If you want a file to sync with your team instead of your global default, you drop a filesync.config.toml next to it (or anywhere up the tree) pointing at a shared remote:
remote = "git@github.com:yourteam/shared-configs.git"
When you push or pull, filesync walks up from the file looking for the nearest config. If it finds one, that's the repo; if it doesn't, it falls back to your global default. So your private files keep syncing there, the shared ones sync to the team remote, and the two never cross, because the routing is purely about where the file lives on disk.
The reason the header deliberately doesn't store the remote: I didn't want cross-repo names leaking into a codebase. A filesync.config.toml can only ever name its own one repo, which makes it completely safe to commit, even into a shared private repo. So you check it in alongside the rest of the project, a teammate clones the repo, and filesync just starts working for them in that project too. No setup step, no "hey, did you run init," because the config traveled with the code.
It's a single main.go (plus tests) up on GitHub if you want to poke at it: github.com/sparkstonepdx/filesync.
This remind you of a problem you've been dealing with?
I'd be happy to discuss it with you and see if it's something we could turn into a solution.
Book a call