I have a repository problem that .gitignore does not solve.
Vihren is an open source project, but the repository I work in every day also contains private material: plans, product notes, architecture decisions, experiments, private CI, and agent context. Those files are not disposable. They explain why the public code is shaped the way it is, and I want them versioned next to the code they describe.
At the same time, I want the public repository to be ordinary. The Go module
should live at the root. Public contributors should not clone an oss/
subdirectory export. The public history should not contain encrypted blobs,
internal notes, or private workflow files.
The workflow I settled on uses Jujutsu as a local public/private graph:
- public paths live at the repository root;
- private paths live behind a
privatefileset; - the public line is the publishable history;
- the private line is the daily working history;
- sync merges advance the private line when public advances;
jj split --ontopeels public deltas out of mixed work when needed.
The important part is the sync merge model. The private line is not constantly rebased on top of public. Instead, it records “public advanced to here” as a merge with an empty authored delta. That gives me public history at the boundary and honest private memory behind it.
This is not a secret manager. Plaintext credentials should still live in a vault, in environment variables, or in an encryption workflow. This is about a larger category: private context that is useful to track, but wrong to publish.
The Boundary
The split starts with a fileset:
[fileset-aliases]
'private' = 'root-glob:"**/private/**" | root-glob:".github/workflows/*.private.yml"'
'public' = '~private'
The convention is simple: any directory named private is private. Vihren also
marks *.private.yml GitHub Actions files as private, because Actions workflows
have to live directly under .github/workflows/.
That gives a path boundary. The publish boundary has to be stronger:
[revset-aliases]
'publishable' = '~(files(private)::)'
[git]
private-commits = '~publishable'
files(private) selects commits whose diff touches private paths.
files(private):: selects those commits and their descendants. Therefore
publishable means: no private path was touched anywhere in this commit’s
reachable history.
That history-aware rule is the difference between “the current tree looks clean” and “this public ref cannot reveal a private blob from an ancestor.” Deleting a private file later does not make a commit publishable. The history still touched the private fileset.
git.private-commits = '~publishable' then turns that rule into a push backstop.
Jujutsu refuses to push non-publishable history unless a command explicitly opts
in with --allow-private.
The Two Lines
There are two logical lines:
public: the line pushed to the public remote.private: the daily working line, containing public paths plus private paths.
The public line is import-closed and publishable. The private line descends from public, but it is not a branch that gets rebased every time public changes.
Instead, public advances through public commits:
R ---- P1 -------- P2 public
Private work advances through private-only commits:
R ---- S1 ---- J1 ---- J2 private
When public moves from P1 to P2, the private line catches up with a sync
merge:
R ---- P1 -------- P2 public
\ \ \
S1 --- J1 --- J2 --- S2 private
S2 has two parents: the previous private head and the new public head. Its
authored delta is empty. It exists to record that the private line now includes
the new public slice.
That empty merge is the key. A private-only commit does not edit public paths, so syncing public forward is conflict-free in the normal case. No private commit is rebased. No private remote needs a force push. Bisecting the private line stays meaningful because its commits are not continually rewritten just because public moved.
The invariant is:
- public commits write only
public; - private-only commits write only
private; - sync merges write nothing.
If that invariant holds, publishing is boring:
jj git push --remote public --bookmark public
There is a separate wrapper for the private remote, and it is the only place that opts in to private history:
jj push-private
Everyday Checks
Because the boundary is a fileset, the same names work in normal jj commands:
jj diff -r @ public
jj diff -r @ private
jj file list -r public private
jj log -r 'publishable'
Those commands are useful because they make the boundary visible. A private file is not private because a README says so. It is private because a fileset matches it, a revset uses that fileset to classify history, and the push guard uses the revset.
The public line still has to prove that it is a real public repository. In Vihren that means the public head builds by itself, public packages do not import private packages, and public CI does not depend on private paths. Jujutsu can keep private history out of the public ref; it cannot make an import graph sane after the fact.
Mixed Work Happens
The clean model says public work lands on public, private work lands on
private, and sync merges connect them.
Real work is messier. Sometimes a private-line stack contains both public code
and private notes. That is where jj split --onto is useful.
The helper script walks a private stack and creates:
- a public rail, containing only public-path deltas;
- a private ladder, containing the residual private deltas and merge links back to each public projection.
The shape looks like this:
P0 ---- P1 ---- P2 public rail
\ \ \
S0 ---- A' ---- B' ---- C' private ladder
The core operation is:
jj split -r "$source_change" --onto "$public_cursor" public
That says: from this source change, take the public fileset and place that public delta on the current public cursor.
The full helper is small:
#!/usr/bin/env bash
# jj-split-stack PUB_BASE PRIV_BASE [HEAD] (HEAD defaults to @)
# Split PRIV_BASE..HEAD into a public rail on PUB_BASE and a private ladder on
# PRIV_BASE. Bases must be aligned (pub_base == public projection of priv_base).
set -euo pipefail
[ $# -ge 2 ] || { echo "usage: jj-split-stack PUB_BASE PRIV_BASE [HEAD]" >&2; exit 2; }
pub_base="$1"; priv_base="$2"; head="${3:-@}"
if [ -n "$(jj log --no-graph -r "aligned_violation($pub_base, $priv_base)" -T 'commit_id ++ "\n"')" ]; then
echo "error: $pub_base is not the public projection of $priv_base" >&2; exit 1
fi
# Cursors are commit-ids. The stack is walked by change-id because split/rebase
# rewrite commit-ids but preserve change-ids.
public_cursor="$(jj log --no-graph -r "$pub_base" -T commit_id)"
private_cursor="$(jj log --no-graph -r "$priv_base" -T commit_id)"
for c in $(jj log --no-graph --reversed -r "stack($priv_base, $head)" -T 'change_id ++ "\n"'); do
if [ -n "$(jj diff -r "$c" --name-only public)" ]; then
jj split -r "$c" --onto "$public_cursor" public \
-m "$(jj log --no-graph -r "$c" -T description)"
public_cursor="$(jj log --no-graph -r "exactly($public_cursor+ & files(public), 1)" -T commit_id)"
jj rebase -s "$c" -o "$private_cursor" -o "$public_cursor"
fi
private_cursor="$(jj log --no-graph -r "$c" -T commit_id)"
done
jj bookmark set public -r "$public_cursor"
jj bookmark set private -r "$private_cursor"
There are two details that matter.
First, the script walks source commits by change ID. jj split and jj rebase
rewrite commit IDs, but the change ID remains stable.
Second, it uses --onto, not --insert-after. Here the public projection should
be placed on the public cursor without moving existing private descendants.
The alignment check protects the precondition:
[revset-aliases]
'stack(base, head)' = 'base..head'
'aligned_violation(pub, priv)' = '(pub ~ publishable) | (priv ~ pub::) | ((pub..priv) & files(public))'
In words:
- the public base must itself be publishable;
- the private base must descend from the public base;
- the private-only gap between them must not contain public-path work.
That last point matters because the script is not a magic synchronizer. It is a projection helper for an already-disciplined graph.
Where This Fits
The obvious use case is an open source project with private operating context. Public code, examples, and documentation live at the root. Plans, notes, private experiments, and release evidence live under private paths.
The same shape fits an open source core with proprietary extensions. Public packages can be released normally, while private connectors, hosted-service workflows, customer-specific adapters, or paid-edition tests stay tracked on the private line. The hard rule is that public packages must not import private packages.
It also fits jj workspaces with tracked local configuration. Jujutsu already makes multiple working copies backed by one repo feel natural. Some local workspace material is not secret, but also not public: fixture manifests, local service endpoints, long-running test notes, or run profiles. A private fileset is a better fit than pretending those files are scratch.
Other cases have the same structure:
- public tutorial, private solution set;
- public library, private regression corpus;
- public patch, embargoed security notes;
- public toolkit, private client overlays;
- public code, private AI-agent traces and planning context.
The common property is adjacency. The private material is most useful when it lives next to the public code it explains.
Prior Art
This is not a replacement for projection infrastructure like Copybara or Josh.
Copybara is the right reference point when you need a sync service between repositories, policy around transformations, path rewriting, labels, and CI integration. Josh is the right reference point when you want filtered Git views and reversible history transformations.
This workflow is smaller because the problem is smaller. The projection is a fileset, both logical lines live in one jj repository, and the public repository is rooted the same way as the private working tree.
It is also not the same as git-crypt. git-crypt keeps encrypted blobs in the repository and controls who can decrypt them. Here the public line should not contain the private paths at all, encrypted or otherwise.
And it is not sparse checkout, skip-worktree, .gitignore, or direnv. Those
are working-tree and runtime-configuration tools. They do not create a public ref
whose reachable history is private-free.
Finally, it is not an incident-response tool. If private history has already reached a public remote, you are in the world of history cleanup, coordination, rotation, and revocation. This workflow is meant to make that accident harder to create.
Sharp Edges
The guard is only as good as the fileset. If private material lands outside the matched paths, jj cannot know it is private. The real adoption work is making the path convention boring enough that future files naturally land in the right place.
The public line has to build by itself. If public code imports a private package, the projection is not a usable public repository. That needs normal build and import-graph checks.
The private remote still needs access control. Everyone who can read it can read the private files. If you track actual credentials in plaintext there, you have not solved secret management.
git.private-commits is a push backstop, not time travel. It refuses private
history before it is pushed. It does not make already-published commits
disappear from other people’s clones.
And the graph is more explicit than a simple linear branch. You trade the old rebase treadmill for merge-shaped history. I think that is a good trade: the graph says what happened, and private commits are not rewritten just because public moved.
Why jj Makes This Work
I do not think the interesting piece is any single jj feature. The interesting piece is the composition:
- filesets give the path boundary a name;
- revsets turn that boundary into a history predicate;
git.private-commitsturns the predicate into a push refusal;- merge commits let the private line sync public without rebasing private work;
jj split --ontoprojects public deltas out of mixed work;- stable change IDs make the helper script robust while commits are rewritten;
- workspaces let the whole setup stay in one local repository.
That combination is enough to keep private context first-class without making the public repository strange.
For Vihren, that is the shape I wanted: public code at the boundary, honest memory behind it, and a version-control graph that records both.