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 private fileset;
  • 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 --onto peels 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-commits turns the predicate into a push refusal;
  • merge commits let the private line sync public without rebasing private work;
  • jj split --onto projects 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.