Projection: A jj Workflow for splitting public and private files | VihrenSkip to content
Back to blog2026-06-25<br>Projection: A jj Workflow for splitting public and private files<br>How to track private files together with public code with Jujutsu filesets, revsets, and git private commits setting.<br>While working on Vihren I<br>encountered the following issue. Vihren is intended to be<br>open-source. At the same time there are files that I’m not ready to<br>share as part of the public repository. Mostly those are files that inform<br>my agents how to drive development. Some of them are product<br>documentation containing internal goals, a roadmap or ideas for<br>monetisation. Some of the work consists of experiments and half-baked<br>ideas that I haven’t discovered the final shape of yet.<br>All of that private context needs to be version-controlled. At the same<br>time I don’t want it to end up in the public repo by accident.<br>The need for version controlling of private context also comes from<br>working in parallel. Jujutsu offers jj<br>workspaces<br>which allow for agents to explore several ideas simultaneously without<br>any interference. But in order for this to work setting up a new jj<br>workspace should be very simple. If all work is version-controlled, I<br>can just do:<br>jj workspace new ../vihren-workspace-xyz<br>cd ../vihren-workspace-xyz<br>direnv allow
The last command direnv allow can set up a complete development<br>environment based on Nix.<br>This approach can only work if all files that I care about are tracked<br>by jj.<br>So the need came to find a way to work on an open-source project while<br>at the same time maintain private data which shouldn’t go out-of-sync<br>with the publicly visible stuff.<br>Alternatives<br>Why not .gitignore? Ignoring a file makes it untracked — it<br>lives on my disk and nowhere else. That is the opposite of what I need:<br>the private context has to be version-controlled and travel with the<br>repo, so a fresh jj workspace plus direnv allow reproduces it.<br>.gitignore solves “don’t publish” by discarding the history I am<br>trying to keep.<br>The other options each give up one of the two things I want — context<br>that is tracked and adjacent , and a public history that never<br>contains private paths :<br>A separate private repo keeps both histories but loses adjacency:<br>I cannot commit a code change and its private note together, and the<br>two drift out of sync.<br>A submodule for the private directory stays versioned, but it is<br>a second history with its own commit-and-push dance, and the pointer<br>still rides along in public. Also Jujutsu doesn’t currently support<br>git submodules which makes working with workspaces difficult<br>git-crypt keeps the private files in the public repo, only<br>encrypted; I do not want them in public history at all.<br>sparse-checkout or skip-worktree only hide files in the working<br>tree — they never produce a private-free history to push.<br>The heavyweight version is a real projection service like<br>Copybara or<br>Josh, syncing between two<br>repositories. What follows is the small version: one local repository,<br>and three jj features — filesets, revsets, and the git.private-commits<br>setting — doing the rest.<br>And what about the megamerge workflow<br>The megamerge<br>workflow<br>already covers part of this problem. In that workflow, you make a<br>local multi-parent merge commit over the branches and private context<br>you want visible in one working tree. You work on top of that combined<br>tree, then move the resulting changes back onto the branches that<br>should own them. The megamerge itself stays local; only the resulting<br>branches may be pushed.<br>In many cases that is enough. Where it is not enough for my use<br>case is the historical relation between a public commit and the<br>private context that produced it. After a normal megamerge workflow<br>moves the public changes back onto the public branch and the private<br>specs back onto a private branch, the graph no longer records which<br>private state belonged to which public commit. I use agents with<br>spec-driven development, so I want to answer this from jj history<br>itself: what was the state of the specs, plans, and agent notes that<br>led to this public commit?<br>I call this workflow Projection . The private history is allowed to<br>contain the full working context, but it carries a queryable projection<br>onto the public history. That projection is not a separate repository or<br>an export job; it is encoded directly in the jj graph by the merge edges<br>between the private line and the public line.<br>The Projection Shape<br>We want to split files into public and private. And we want to maintain<br>lines of commits which contain only public files and which can be<br>freely pushed to a branch in the public repo. At the same time we want<br>to keep track of the private context associated to each public<br>commit. So we are aiming at the following structure of our log<br>history:<br>public: P0 -------- P1 -------- P2<br>\ \ \<br>private: S0 -------- S1 -------- S2 -------- S3<br>^ ^<br>| |<br>merge P1 merge P2
Each private commit branches from the...