Stackspaces
Some projects only make sense together — an app, and the libraries it depends on that you have had to fork. Iterating across that boundary means publishing a package to see a change land, and a change that spans the repos cannot be one commit.
A stackspace fuses those repos — your app included — into one git history, each under its own directory. A change spans them in a single commit, the build compiles against source rather than packages, and every project still leaves as itself: a pull request to a fork you contribute to, or a fast-forward of a repository you own. Nobody downstream learns the stackspace exists.
The two halves
Two independent problems, two independent solutions. You need both, and they know nothing about each other.
The git half. Each upstream repo's history is imported into one stackspace repo, rewritten to live under a prefix directory. That is josh doing reversible history filtering; rig drives it as a throwaway localhost process and does everything else with plain git. Because it is one repo, one commit can touch every project in it.
The whole round trip, for two of the repos:
Work arrives one way and leaves two, depending on whose repo it is going back to. propose proposes a squashed commit on a branch of your fork, which is what a maintainer reviewing someone else's project wants. push fast-forwards a project you own with every commit that touched it, messages intact, which is the only sane thing to do to a repository that is yours.
The engine is not involved everywhere. Import, pull and push all drive josh's reversible filters — in one direction to nest a repo under a prefix, in the other to take it out again. propose uses no engine at all: the directory's tree is already what upstream wants, so the export is a plain git commit-tree onto the upstream tip.
The build half. If the projects depend on each other through a package registry, your edits do not reach the consumer without a publish cycle. A build file sitting above the projects redirects those package references at the sources next door — see wiring the build below. No upstream-owned file is modified, and no project file changes at all.
Everything goes in the stackspace
Your own project included. One repo, one build file, one commit that can span all of it.
That is worth saying plainly, because the instinct is the other way round: keep your app where it is, and have it reach into the stackspace for the libraries. That does work. It also gives up the thing the stackspace exists for — a change spanning your app and a library is two commits in two repos again — and it needs a second build file that most people do not realise they need until something silently builds against the published package.
A build overlay is found by walking up from a project, so it governs its own directory tree and nothing else. Put every project in one tree and one file redirects every dashed line above — including the ones between two libraries, which is the pair people forget when their app sits outside.
Your app is a member like any other, with one difference you declare: mark it "owned": true and rig stack push fast-forwards its own repo with your commits, history intact, instead of proposing a squashed branch to a fork the way propose does for somebody else's project.
A repo with its own root build file hides the overlay
MSBuild stops at the first Directory.Build.targets it finds walking up. Your own app is the project most likely to have one — and if it does, the stackspace overlay above it is never read, and every project underneath quietly keeps building against published packages. Nothing warns you.
Have that file continue the walk-up:
<PropertyGroup>
<!-- Resolved into a property first: a Condition is itself single-quoted, so
the quotes this function needs cannot appear inside one (MSB4092). -->
<StackParentTargets>$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)../'))</StackParentTargets>
</PropertyGroup>
<Import Project="$(StackParentTargets)" Condition="'$(StackParentTargets)' != ''" />It is a no-op outside a stackspace, where there is nothing above, so it is safe to commit.
Setting one up
1. The stackspace is an ordinary git repo
It needs no remote and never gets one. Nothing is pushed from it except through propose, which pushes to your forks.
mkdir ~/src/my-stack && cd ~/src/my-stack
git init -b main2. Scaffold the manifest
The first init writes a commented skeleton and stops — it cannot guess your repos. Fill it in, and the second init imports them.
rig stack init # writes rig.stack.jsoncOr skip the editing entirely. rig stack add asks for a repo, works out where it goes, writes the entry and imports it — and every answer is also a flag, so the same thing scripts:
rig stack add # asks
rig stack add github.com/acme/pty-core --fork github.com/you/pty-core
rig stack add github.com/you/term-app --owned # one of yoursPaste the URL if that is what you have; it is reduced to host/owner/name. The rest of this section is what add writes, and what to write by hand if you would rather.
{
"$schema": "https://rigsmith.dev/schemas/rig-stack.json",
// Branches `propose` creates are named stack/<what-you-typed>. Optional.
"branchPrefix": "stack/",
"repos": {
"pty-core": {
"upstream": "github.com/acme/pty-core", // where PRs go
"fork": "github.com/you/pty-core", // where `propose` pushes
"upstreamBranch": "main" // branch of upstream to follow
},
"term-core": {
"upstream": "github.com/acme/term-core",
"fork": "github.com/you/term-core",
"upstreamBranch": "main"
},
// Your own project, fused like any other member. "owned" is what makes
// `push` available for it — see Getting your changes out.
"term-app": {
"upstream": "github.com/you/term-app",
"fork": "github.com/you/term-app",
"owned": true
}
}
}The key is the directory the project will live under. Repo specs are host/owner/name — no scheme, no .git — because the same string has to serve as a URL, an engine path, and a label.
upstreamBranch is the branch of upstream this directory follows: what pull takes, and what propose roots its commit on. It is not the branch propose creates — you name that one per change. Full key reference in Configuration.
3. Import
rig stack init
# pty-core: imported upstream a1b2c3d4
# term-core: imported upstream e5f6a7b8
# term-app: imported upstream 9c0d1e2fEach repo's history is fetched through its prefix filter and merged in. The first run also acquires the josh engine. For the version rig pins, on a platform it publishes for, that is a verified download and takes seconds; pin a different josh version or work on a platform without a published binary and it falls back to building from source, which takes minutes.
You now have one history, one directory per project, and a lastSync block in the manifest recording the upstream commit each was taken from — written by the import, and by every pull after it. Those cursors are committed alongside the import, which is what makes a repeated pull a no-op rather than a surprise.
4. Wire the build
This part has nothing to do with git, and what it looks like depends on your ecosystem. The goal is the same everywhere: make every project compile against the sources next door instead of a published package.
On .NET that is a single Directory.Build.targets at the stackspace root. MSBuild imports it from the nearest ancestor directory of every project underneath, and in a stackspace that is all of them.
rig stack wire writes this file for you, from what the ecosystem adapters already know about the projects in the stackspace — which package each one produces, and which of them are referenced across a member boundary. Those are the ones that would otherwise come from a registry, and they are exactly the ones it redirects. Re-run it after adding a member. It rewrites its own file and refuses to touch one you wrote yourself.
What it writes, and what to write by hand if you would rather — each swap declared once, the package name and where its sources are:
<Project>
<ItemGroup>
<StackSource Include="Pty.Core" Path="pty-core/src/Pty.Core/Pty.Core.csproj" />
<StackSource Include="Term.Core" Path="term-core/src/Term.Core/Term.Core.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(UseStackSources)' != 'false'">
<_StackAbsent Include="@(StackSource)" Exclude="@(PackageReference)" />
<_StackPresent Include="@(StackSource)" Exclude="@(_StackAbsent)" />
<ProjectReference Include="@(_StackPresent->'$(MSBuildThisFileDirectory)%(Path)')" />
<PackageReference Remove="@(StackSource)" />
</ItemGroup>
<PropertyGroup Condition="'$(UseStackSources)' != 'false'">
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
</PropertyGroup>
</Project>Exclude matches on identity, so _StackAbsent is every declared source a project does not reference and _StackPresent is the complement — exactly the swaps that project needs. Only actual consumers get rewired, which is what stops Pty.Core gaining a reference to itself.
The last block is there for anything that reads a dependency's internals. A package without a separate ref/<TFM> asset — most of them — hands the consumer its implementation assembly, and a publicizer such as IgnoresAccessChecksToGenerator rewrites what the compiler sees. A project reference instead produces a reference assembly, internals stripped, and hands the consumer that one — so the publicized copy is built and then ignored, and every internal comes back as CS0122 on members that did not change. Turning reference assemblies off inside the stackspace is a trade: they exist so a change to a dependency's implementation does not recompile its consumers, and without them an incremental build recompiles more of the graph. A stackspace accepts that — correctness across the boundary is what it is for — and wire writes the property for every project the overlay reaches rather than trying to match the redirected ones by path, which could fail silently across platforms. -p:UseStackSources=false switches it off along with the swaps.
What that buys is one unedited project file with two possible resolutions:
No .csproj is edited, so nothing any project carries advertises the stackspace. Clone any member on its own and it builds from packages exactly as it always did — which is what its CI, and anyone you send a change to, will do.
Other ecosystems have their own version of this — an npm workspaces array, a go.work file, a linked dependency. Nothing in rig stack depends on which you choose.
A fork republished under your own id
If you publish a fork's patched builds to a private feed — so the app can be developed without the stackspace — the safe way is under a distinct id, Acme.Foo rather than Foo: it cannot collide with the public package, and a consumer's project file says plainly that this is not the stock one. But then the app references Acme.Foo, and no project in the stackspace declares that id, so wire has nothing to key a redirect on. The app quietly resolves Acme.Foo from the feed, inside the stackspace, and the build succeeds without testing the fused code.
Say it in the manifest, which is the one place outside every prefix:
"foo": {
"upstream": "github.com/them/foo",
"fork": "github.com/you/foo",
"publishesAs": { "Foo": "Acme.Foo" }
}wire then redirects Acme.Foo to the project producing Foo, and doctor lists it as Acme.Foo (Foo, republished). publishPrefix: "Acme." does the same for every package the member produces. See Configuration.
Checking that it took
rig stack doctor reports the same findings wire does, without writing anything. Both name every package that crosses from one member to another and which way it goes — including library to library, the pair people forget, because the app is not involved and nothing looks wrong.
Both also flag a member whose packages nothing here consumes. That is nearly always one of two mistakes, and silent either way: the wrong repo was fused, or the right one was and your code has since moved to a fork that renamed the package. A package is matched by identity, not by where it came from, so a stackspace like that imports, wires and builds while changing nothing.
wire goes one step further where it is allowed to. A member carrying its own root Directory.Build.targets ends MSBuild's search there — see below — and for a member marked "owned": true wire patches it, since the file is yours and the line belongs committed. For a fork you contribute to it only reports: that line would otherwise ride into somebody else's pull request as rig plumbing.
For a specific project, ask MSBuild what it actually evaluated. Do not infer it from a build succeeding; a build that quietly used the package succeeds too:
dotnet msbuild App.csproj -getItem:PackageReference -getItem:ProjectReferenceEach item carries a DefiningProjectFullPath naming the file that contributed it, so you can confirm a reference arrived from the overlay rather than the .csproj. A package you expected to vanish still listed there means the swap missed it.
Things that will bite you
- A member with its own root
Directory.Build.targetshides the overlay. The most important one, and covered above: the walk-up stops at the first file it finds, and everything under that member keeps building against packages with no warning at all. - Match on
Identity, neverFilename, if you hand-write conditions instead of using the form above. MSBuild splitsFilenameat the last dot, soPty.Core.Nativehas theFilenamePty.Core: a condition meant for one package matches its sibling and looks like it worked. TheExcludeform never names a metadata field, so it cannot go wrong that way. ItemGroupis a child ofProject. Nested insidePropertyGroupit gives youMSB4004: The "ItemGroup" property is reserved, which does not sound like what it means.- Swapping a package for a project reference can break a publicizer. Anything that reaches a dependency's internals —
IgnoresAccessChecksToGeneratorand friends — rewrites@(ReferencePath), but the compiler reads@(ReferencePathWithRefAssemblies). Those are the same items for a package and different for a project reference, which produces a reference assembly. The publicized copy is built and then ignored, and every internal it was meant to expose comes back asCS0122. The overlaywirewrites turnsProduceReferenceAssemblyoff for exactly this reason. If you hand-wrote yours, add that block yourself —doctorcompares only overlays rig wrote, and reports one of those that predates the block as out of date, as it reports an overlay that was never written at all, or one left over after the last cross-member reference went: each is a way a stackspace builds cleanly against the registry and says nothing. - Nothing warns you when part of an overlay does nothing. Removing a
PackageReferencefor something that is actually aProjectReference— a vendored copy, say — is valid MSBuild and a silent no-op. Delete blocks that turn out not to apply, because they read as working.
Fusing a library your code pins to an old release
If a member's sources no longer compile against the rest of the stackspace, the build wiring is usually not the problem — the imported point is. A library you depend on at an older release has to be fused at that release, not at a tip whose API has moved on. Pin it with upstreamTag or upstreamCommit.
The daily loop
There is nothing special about it. It is one repo, so you work in it like one repo.
# change a library and the app that calls it, together
$EDITOR pty-core/src/Pty.Core/PtyConnection.cs
$EDITOR term-app/src/Terminal/Session.cs
rig build # compiles against source, no publish cycle
git commit -am "fix the read timeout and the app that hit it"One commit spanning your app and a forked library, and the build proved it works end to end before you committed. No version bump, no publish, no waiting for a feed. That is the whole point of the exercise, and it is the part you lose if your app lives outside the stackspace.
Getting your changes out
A commit in the stackspace can touch several projects. Getting it to each of them is one command per project — and which command depends on whose repository it is.
Your own projects: push
For a member marked "owned": true, push fast-forwards that project's own branch with every stackspace commit that touched it:
rig stack push term-app
rig stack push # the same, when only one repo here is yours
# pushed term-app to you/term-app:main (0b001bd3)--dry-run shows the target, the branch and the commits that would go, one per line, and stops there: nothing reaches the remote, and nothing local records a push. It is the way to see what a repository is about to receive before it does.
rig stack push term-app --dry-run
# would push term-app to you/term-app:main (0b001bd3)
# 0b001bd3 term-app: handle the new timeout
# 9c4e21a0 cross-cutting: bump the pty versionNothing is squashed. Each commit arrives with its own message, parented on what the repo already had — so a change spanning your app and a library lands as a matching commit in each, and commits that touched nothing in that directory do not appear at all. It works by running the exact inverse of the filter the repo was imported with, so the history you share with upstream comes back as its own commits and yours sit on top as a fast-forward.
Pushing also brings the result back into the stackspace before it returns. The commit that leaves is necessarily a different object from the one that produced it — the same content, under a different prefix, with different parents — so the stackspace ends up holding both shapes of a cross-project change: your commit spanning several projects, and the single-project commit the repo received. That is the honest cost of one history being several, and taking it back at push time is what stops a later pull re-importing your own work as a parallel line of development.
Somebody else's: propose
One propose per project. Each produces a branch on your fork holding a single commit whose diff is exactly what you changed in that directory, rooted on the current upstream tip.
rig stack propose <repo> <new-branch>
rig stack propose pty-core read-timeout -m "Fix the read timeout"
rig stack propose term-core read-timeout -m "Handle the new timeout"
# sent pty-core to you/pty-core:stack/read-timeout
# — open the PR against acme/pty-coreThe branch holds that project's files at their real un-prefixed paths — src/Pty.Core/…, not pty-core/src/… — with no sign the repo is fused with anything, and none of the stackspace's own history for a maintainer to read around. Open the PR from your fork as usual.
<new-branch> is a branch you are creating on your fork, named per change. Nothing reads it from the manifest, because it is a property of the change rather than of the project — which is also why the rig ui flow asks for it.
--dry-run says which commit it would push, to which branch of the fork, and which upstream it is for, and stops there: nothing reaches the fork, and nothing local records a proposal.
What you type is prefixed with stack/, so read-timeout becomes stack/read-timeout. Your fork also carries your own branches; the prefix keeps these apart from them at a glance, and makes a collision with a name already on the fork far less likely — though a prefix reserves nothing, and a fork may already carry stack/<name>. Change it with branchPrefix in the manifest, per stackspace or per repo, or set it to "" for bare names. A name that already starts with the prefix is left alone, so pasting a full branch name back in when proposing again does not stutter it.
It sends the whole prefix, not the change you have in mind
propose commits the prefix's entire current tree onto the upstream tip. It does not extract one change: whatever this stackspace is holding for that project — every fix, including ones already waiting in another pull request — is what the branch carries.
While one thing is in flight that is exactly right, and it is why a rebuild elsewhere is safe: the branch a member was last proposed to holds everything, so init reconstituting from it gets all of it.
The moment two things are in flight for one project it is wrong. Fix A sits unmerged in the stackspace; you propose fix B; B's branch contains A as well, and its pull request shows changes its reviewer never asked about.
One fix at a time: --from a topic branch
Keep each in-flight fix on its own branch of the stackspace, rooted on the commit that imported that member. main merges them, and stays what it always was — everything fused, the thing you build and test.
import ── fix A ─────────────── merge ── main: both fixes, what you build and test
└───── stack-pr-reader-wedge ───┘ topic: upstream plus this fix, nothing elsegit switch -c stack-pr-reader-wedge <the import commit>
# ...fix it, commit...
git switch main && git merge stack-pr-reader-wedge
rig stack propose term-core reader-wedge --from reader-wedge
# term-core: proposing what stack-pr-reader-wedge adds, not the whole prefixstack-pr-<name> is a recommended convention, not a rule. --from takes any branch: an exact name always wins, and the conventional name is only the fallback when what you gave it is not itself a branch — so --from reader-wedge finds stack-pr-reader-wedge without you spelling it out. The prefix is deliberately not stack/, which names the branches that appear on your fork: those two live in different repositories and mean different things, and sharing a spelling would only invite reading one as the other.
rig stack status lists the ones named that way, so what is in flight is something you read rather than remember:
topics in flight: stack-pr-reader-wedge, stack-pr-kitty-scaling
propose one with `rig stack propose <repo> <name> --from reader-wedge`A topic rooted on the import holds upstream's tree plus its own change and nothing else, so its tree is already the one upstream should see — there is no patch to replay and nothing that can fail to apply. That matters more the longer you carry work: reconstructing one change out of an intertwined history is a patch that stops applying, while a topic keeps the isolation by construction.
Branch off a line that already carries another unmerged fix and the topic contains that fix too. propose does not refuse — it cannot: the topic genuinely does contain it, and which commits make up a change is exactly what the branch encodes, so only you can say whether it was meant. What it does is say what it is sending:
term-core: proposing stack-pr-reader-wedge — 2 commit(s) since the import
a1b2c3d4 term-core: the reader wedge
e5f6a7b8 term-core: an earlier fix, still in reviewA subject you did not expect is the signal to rebase the topic onto the import.
Where everything is: status
rig stack status lists each member's topics under it, and where each one went:
lib a1b2c3d4 up to date · `propose` sends this prefix's whole divergence (--from <branch> for one topic)
stack-pr-reader-wedge → you/lib:stack/reader-wedge
stack-pr-kitty-scale not proposed yetAlmost all of that is derived from the repository each time it runs — which topics exist, which member each one touches, whether it went stale behind a pull. What is recorded, in the manifest under proposals, is only what the repository cannot be asked: where the pull request went, and which commit was sent there.
"proposals": {
"lib": { "stack-pr-reader-wedge": { "branch": "stack/reader-wedge", "commit": "a1b2c3d4…" } }
}The commit is the topic's tip when it was proposed. Records are keyed by branch name, and names get reused — delete a topic once its pull request merges, start another with the same name later, and the old destination would be reported for the new work. Comparing what the branch is now against what was sent catches that, and answers the commoner question too:
stack-pr-reader-wedge → you/lib:stack/reader-wedge (branch has moved since; propose again to update it)lastPropose cannot serve this. It holds one branch per repo and is overwritten on every propose, which was enough while a proposal meant the whole prefix — there could only ever be one in flight. Two topics for one member are two pull requests, and it remembers the second. It keeps its own job: naming the branch a rebuild reconstitutes from.
Nothing else is recorded on purpose. A list of topics in config would drift the moment a branch was deleted, and leave the manifest claiming work that is not there; deriving it means a topic simply stops being listed once its pull request merges and you delete the branch.
A pull leaves topics behind
pull moves the prefix on. A topic branch does not come with it, so its tree is upstream as it used to be — and committing that onto the current tip would present everything upstream landed since as though your branch had reverted it. That is the same hazard the stale-cursor refusal covers for a whole-prefix propose, and --from cannot rely on that one: HEAD gets pulled, and a topic does not.
So propose --from refuses a topic that does not have the current cursor in it, and both pull and status name them unprompted — the pull that caused it may have been days ago:
lib: 1 topic(s) rooted before this pull — `propose --from` will refuse them until re-rooted:
stack-pr-reader-wedge
branch again from the new import and replay the fix; proposing as-is would revert what upstream landed.Why rig does not re-root them for you
The obvious target is the commit the pull just made — and it is the wrong one. An import merges into HEAD, and HEAD has already merged your topics, so re-rooting a topic onto it folds the very fix that topic isolates straight back in. Doing it correctly means replaying the topic onto upstream's new tree with none of the integration line's fixes, which is a different operation from a rebase and is not built yet.
--from requires trackBranch
A topic holds part of what the prefix carries, so it cannot also be what a rebuild reconstitutes from — a fresh init would build without the other fixes, while your own worktree still had them and looked fine. trackBranch is where the whole divergence lives, and propose --from keeps it current on every send:
rig stack propose term-core reader-wedge --from reader-wedge
# term-core: proposing what reader-wedge adds, not the whole prefix
# sent term-core to you/term-core:stack/reader-wedge
# term-core: stack/integration now carries everything, for rebuildsWithout it set, --from refuses rather than publish a package or a rebuild that is quietly missing work.
rig stack status says that propose sends the prefix's whole divergence, so you find out before a maintainer does:
term-core e5f6a7b8 up to date · `propose` sends this prefix's whole divergence (--from <branch> for one topic)Sending again to the same branch updates it, so you can act on review feedback: commit in the stackspace, propose again, and the pull request moves. The branch is replaced under a lease taken at the moment of the push, which guards against a race between reading the branch and writing it — it is not a record of what you last sent, so it will not tell you that someone rewrote the branch between one proposal and the next.
propose refuses a stale cursor
Your stackspace tree is a snapshot taken at the last pull. If upstream has moved on since, committing that tree onto the newer tip would present every commit that landed in between as though your branch had reverted it. propose stops rather than build such a branch — run rig stack pull <repo> and propose again.
Taking upstream's changes
rig stack status # who has moved, per repo
rig stack pull pty-core # take one
rig stack pull # take all of themA pull merges the new upstream commits into that repo's directory, so a conflict is scoped to the project that caused it rather than landing across the whole stackspace. The cursor only advances once the merge is committed: resolve the files, commit, and run the pull again — it recognises the merge you finished, records the cursor into that commit, and does not second-guess how you resolved it. Anything you committed on top since stays as it is; the cursor then gets a small commit of its own.
A pinned project has nothing to take: its tag was resolved once, and an upstream that later re-cuts that tag does not move you. Edit the pin to go somewhere else, or rig stack pull --repin to follow a moved tag deliberately.
Moving a pin backwards — to a release older than the directory currently holds — cannot be a merge, since the target is already an ancestor. The directory is replaced with the pinned revision instead, reported as moved, and refused outright if it holds changes of its own.
Taking a repo back out
Not every repo that looks fusable can be built fused, and that tends to surface only after the import — a consumer that reads another member's internals through a publicizer, say, which needs a compiled assembly and finds a project reference instead. The honest answer there is that the repo should not be a member, and rm is how you say so:
rig stack rm pty-core
rig stack rm pty-core --keep-tree # leave the directory; it just stops being a memberRemoval touches three things, and only one of them is visible, so it does all three and says which: the manifest entry and its cursor go; the directory is deleted from the tree (or kept, with --keep-tree, as an ordinary part of this repository); and the build overlay is rewritten from the members that remain — or removed outright when nothing crosses between them any more — so no build file keeps a ProjectReference into a directory that is no longer there. The result is one commit. Nothing outside the stackspace changes: the upstream and your fork are exactly as they were, and rig stack add fuses the repo again.
A repo holding work that has not left the stackspace is refused, because that work exists nowhere else — the same unsent changes and uncommitted changes that status reports, plus the case where the comparison cannot be made at all. Commits are counted as sent once propose has put them on your fork (it keeps the commit it pushed under refs/rigsmith/propose/<repo>, and a prefix holding exactly that tree has nothing left to send). Before relying on that, rm asks the fork whether the branch still holds the commit as pushed: a branch moved or deleted since, or a fork that cannot be asked, is refused rather than guessed about. A directory holding files git ignores — build output, a local .env — is refused too, naming them: no history gets those back. --force removes it anyway; --keep-tree leaves them where they are. --keep-tree wants a clean prefix, whatever else is passed: the removal commit stages the whole tree, so uncommitted edits under a directory that stays would ride into it unread — commit or stash them first.
Moving a stackspace to another machine
A stackspace is a local artifact, and for a while the only thing that reproduced one was the fused repo itself — a hundred megabytes carrying three upstreams' histories, and a derived artifact under version control, which is the kind of thing that drifts. Two things make a smaller seed enough:
- Everything at the root outside every prefix is the stackspace's own. The manifest, a
Directory.Build.rsp, apackaging/directory that publishes the members so the app can be developed without the stackspace — anything kept out of the members because it would otherwise leave in a pull request. It is committed to the stackspace's own history, andseedexports exactly that set. - The manifest remembers what each prefix held.
lastSyncis the commit each directory was taken from, so a directory that is missing can be rebuilt at that commit rather than at upstream's tip.
rig stack seed ../rigstack-acme # root files → a new repo, one commit (--force past unsent commits)
git -C ../rigstack-acme remote add origin <url>
git -C ../rigstack-acme push -u origin main
# elsewhere
git clone <url> my-stack && cd my-stack
rig stack setup # engine, members, overlay, statussetup is the one command a fresh clone needs. It does the steps in an order where each can see what it is judging — which matters, because the obvious order does not. doctor --fix is what installs the engine, so running it first was unavoidable, and at that moment no member exists: nothing crosses between them, the overlay looks left over, and doctor said to delete it. setup imports first and judges after.
rig stack init also writes a README.md if the repository has none, generated from the manifest: the member table, the setup command, and the two things a seed cannot show — that the directories are missing on purpose, and that work leaves through propose rather than a push. wire refreshes it, so the member table follows the manifest instead of going stale.
Delete the marker line at the top to make the file yours. Editing it is not enough: a file still carrying the marker is one rig owns, and the next init or wire rewrites it — edits and all. Without the marker rig never touches it again, which is also how you opt out of having one at all.
Name the directory — and the repository you push it to — rigstack-<something>. A seed is not a project you clone and work in: it is the few kilobytes stack init rebuilds a stackspace from, and the prefix says so at a glance in a list of repositories. rig stack seed offers a name that follows it, taken from the stackspace's own directory. When the stackspace is nested in another repository, the suggestion moves outside the enclosing repositories. Nothing enforces the name; the argument takes whatever you type.
A seed carries no member, so a rebuilt one holds its cursor or the branch it was last proposed to — and a commit that reached neither is in no seed. seed refuses a member holding such commits, the same way rm does, and asks the fork that proposed work is still there as pushed, the same way too; propose them first, or --force to seed anyway.
init treats a member with a cursor but no directory as one to reconstitute: it imports that member at the cursor, so the stackspace comes back as it was left. A member marked owned comes back with your commits, because push fast-forwarded its own branch and the cursor points there. A fork you contribute to needs one more thing, because propose puts your changes on your fork as a stack/… branch while the cursor still names the upstream commit it was based on — so on its own the rebuilt directory would hold upstream's code and none of yours. Two ways to say where the work is:
- Nothing, in the common case.
proposerecords the branch it last sent each repo to, and wheninitrebuilds a member it checks whether that branch still exists on the fork. If it does, the member is imported from it. If the proposal has merged and the branch is gone, upstream already carries the work and the cursor is the right place to rebuild. trackBranch, explicitly. A branch offorkto import this member from, for a branch you made by hand or want to pin regardless of what was proposed last. See Configuration.
Either way, where pull requests go does not change: upstream is still what propose roots its commit on and what pull follows. The cursor records the upstream commit the fork branch is based on — found by merge-base — so status compares the right things and a later pull merges upstream's movement rather than importing it twice.
Publishing from a stackspace
The stackspace is where the fused build lives, so it is also where a release of it is cut: the app and its patched forks packed together and pushed to a private feed. shiprig knows it is in one — it reads the same manifest — and changes three things without being told:
versiondoes not stamp a member. Everything under a prefix leaves in a pull request to the member's upstream, and a version written into itsDirectory.Build.propswould go with it. The number is still computed and cascaded; it is recorded in.changeset/versions.jsoninstead, read back as the current version next time, and handed to the build as${version.<pkg>}in the release pipeline. The member's changelog goes to the rootCHANGELOG.md, one section per member. A member whose version is computed at build time (MinVer) is discovered as a package all the same, with no version in the tree until a release records one — whether MinVer is aPackageReferencein the project or aGlobalPackageReferencein the nearestDirectory.Packages.props(an outer one only where the nearer file imports it) — provided itsIsPackableis not set to false: the last unconditional assignment in the project or an ancestorDirectory.Build.propswins, and a conditionaltrueanywhere counts as true, since conditions are not evaluated.tagandpushdo nothing. A tag on a fused history names nothing an upstream knows, and the history — three rewritten upstreams fused together — must never reach a remote. The forgereleasestep is skipped with them.publishpushes to the feed and stops. No tag, no push, as above.
So a release.jsonc in a stackspace needs no order that leaves those steps out; the default plan already does, and says why. See Releasing from a stackspace for the step-by-step.
The verbs
| Verb | What it does |
|---|---|
stack setup | Set up a freshly cloned stackspace: the fusion engine, the members, the build overlay, the status — in an order where each step can see what it is judging. Safe to run again |
stack init | Scaffold the manifest, or import the repos it names that are not imported yet; writes the generated README.md unless one is there that rig does not own |
stack add [upstream] | Add a repo to this stackspace and import it; asks when not given |
stack rm <repo> | Remove a repo: its manifest entry and cursor, its directory, and the overlay redirects into it; refuses while it holds work that has not left (--force), --keep-tree keeps the directory |
stack seed <dir> | Export the root files — everything outside every prefix, manifest included — as a small repo that stack init rebuilds the members from elsewhere; refuses while a member holds commits that have not left (--force) |
stack status | Each repo's cursor against its upstream branch tip |
stack pull [repo] | Merge new upstream commits into a repo's directory (all repos by default) |
stack propose [repo] [new-branch] | Put that repo's changes on your fork as a PR-ready branch; --dry-run says what would go and touches no remote |
stack push [repo] | Fast-forward a repo you own with this stackspace's commits, history intact; inferred when only one is yours; --dry-run lists the commits that would go and touches no remote |
stack wire | Write the build overlay so members resolve each other from source, and refresh the generated README.md. Defers while any member the manifest names is not imported: nothing crosses between directories that are not there, which reads exactly like an overlay left over |
stack doctor | Check the engine and manifest; --fix installs what is missing |
propose and push answer different questions. propose proposes one squashed commit on a branch of your fork, which is what a reviewer of someone else's project wants. push fast-forwards a project's own branch with every commit that touched it, messages intact, which is the only sane thing to do to a repository that is yours — mark it "owned": true to enable it.
All of it is in rig ui too, under ▸ Stack — propose there picks the repo from the manifest and prompts for a branch name, and push offers only the repos marked as yours. Tab completion offers your repo names for the verbs that take one.
The engine
Importing, pulling and pushing are done by josh, the git history-filtering engine — josh-proxy serves a repo's history through the filter for init and pull, and josh-filter runs the inverse locally for push. propose needs neither.
Private upstreams work, and need no setup. Because the engine is what talks to upstream, it is the engine that has to authenticate: rig asks the GitHub CLI first, then git's own credential helpers — the keychain, Git Credential Manager, whatever git credential fill answers with — and hands the result over for that one fetch. Nothing new is stored, and rig asks for nothing of its own: terminal prompting is off for the lookup, so a host nothing has a credential for is simply answered "no". An askpass program you configured yourself can still appear, exactly as it would for a direct git fetch of the same host.
gh goes first because the two can disagree and only one of them is maintained. A helper's stored entry is whatever was written the last time something authenticated; where that is an older token it still authenticates, and it still cannot read a private repo.
The credential reaches every step that talks to the forge, not just the one through the engine: ls-remote resolves the tip before the engine starts, and the engine's own git fetch of upstream is a separate process that authenticates for itself. Miss either and the import fails — and it fails quietly, because the engine answers a fetch it could not authorise with an empty history rather than a refusal. An import whose fetch carries no tree is now refused outright rather than recorded as a successful import of nothing. If you see that error, gh auth status is the first thing to check.
rig owns the binaries so you do not have to. rig stack doctor --fix fetches verified builds for your platform — built and published by rigsmith/josh-binaries, since upstream ships no releases — falling back to building it from source where none exists. Nothing runs in the background: the engine starts per operation and stops after. Pin a version per stackspace with the manifest's josh key.
Packing a member
A member's publishable packages are built from here, not from a checkout of the branch you proposed:
rig stack pack mermaider # → dist/
rig stack pack mermaider --out ./out # somewhere else
rig stack pack # every member
rig stack pack mermaider -n # say what would be builtThe reason is the build overlay. A member consumes its siblings by package identity, and the overlay is what points those references at the sibling's source. A bare checkout has neither the sibling nor, necessarily, a feed carrying the version the branch pins — so its restore fails, and NuGet in particular can fail without logging why.
pack refuses while the overlay is not in effect, and says to run rig stack wire. Packing without it would succeed and produce exactly the packages the bare checkout produces, which is the thing being avoided — and nothing downstream can tell the difference.
The build runs with the stackspace as its repository root — that is what makes the overlay apply, and what lets a member's projects see the Directory.Build.props and workspace manifests that sit above them. An adapter that looks for its own config at the repository root therefore looks at the stackspace root: a Go member's .goreleaser.yaml under <member>/ is not found, and such a member reports skipped rather than building. Go modules ship via git tag and have no overlay to benefit from, so this costs nothing today — but it is why pack is about the ecosystems that redirect.
What it lists is what appeared in the output directory, not what the build predicted: a project that computes its version at build time (MinVer and friends) is discovered with no version at all, so a predicted file name would have an empty version in it and match nothing on disk.
Things worth knowing before they bite
A proposed branch is not buildable on its own
This is by design, and it surprises people anyway. A member that consumes a sibling references it by package identity — the stackspace answers that from the sibling's source through the build overlay. A plain checkout of the branch you proposed has neither that directory nor, necessarily, a feed carrying the version the branch pins. Restore fails, and NuGet in particular can fail without printing a reason.
propose now says so, on both the real run and --dry-run:
proposed live-markdown — pushed to you/LiveMarkdown:stack/token-cache, open the PR against them/LiveMarkdown
note: live-markdown references 1 package that this stackspace provides from source, not a feed:
Mermaider live-markdown → mermaider
a plain checkout of this branch has no source for it — pack it from the stackspace,
or make sure the feed the branch expects carries the version it pinsRepublished ids (publishesAs, publishPrefix) are the case most worth naming: the consumer references an id only your own feed ever carries, so "is it on the public registry" is not even the right question.
The note is a note, not a refusal — this is the normal shape of a stackspace proposal. What was missing was only that nothing said so.
- A member built on its own still builds from packages. No project file changes, so a clone of any one repo resolves its dependencies from the registry exactly as it always did — which is what its CI does, and what anyone you send a change to will do. The corollary is that code depending on something you added in a fork but have not published yet compiles inside the stackspace and nowhere else. That is fine while the change is in flight and a trap if you forget it.
- A clean worktree is required for
init,pull,propose, andpush. An import amends its merge commit and stages everything, so an unrelated edit sitting in the tree would be swallowed into it. The one exception is a dedicatedrig.stack.jsoncon first import, since filling it in and re-running is the documented flow — an inlinestackblock in.rig.jsongets no exception, because waving that file through would commit whatever else it holds. - The stackspace root is the git top level, so the verbs work from anywhere inside it — including from within one of the imported projects, which carries its own package manifest and would otherwise look like the root.
- The stackspace is disposable — once your work has left it. The fused history is a working convenience, not an archive, so a tangled one can be deleted and re-imported. But a commit you have not sent or pushed exists only there.
statusflags a project holding work that has not left —unsent changesfor commits,uncommitted changesfor edits still in the tree — so check it before deleting anything. That comparison is local, so it still answers when upstream is unreachable, which is exactly when you are most likely to be tidying up. It reports what has changed, not what has reached a fork: neither verb leaves a record, so a project stays flagged until upstream's own history moves on. - A pull's conflicts are reported where they are, and only the prefix's own are yours. A history fetched through
:prefix=<repo>cannot change anything outside<repo>/, so a conflict reported elsewhere means the merge base already held that path — the fetched history shares a stackspace commit as an ancestor, which a hand-edited manifest or a rough removal and re-add can bring about.pullsettles those paths as the stackspace's own version and says how many, in which directories, and which commit the two histories share — named before the merge is committed, since afterwards the fetched history is simply part of yours. Conflicts inside the prefix are real, andpulllists the files rather than just the directory.git merge --abortsteps back; the cursor is not moved until the merge is committed, so a re-run tries again from the same place — or, once you have committed the resolution, records the cursor for it. - Do not give the stackspace a remote and push it somewhere. It contains several rewritten upstream histories fused together, which is meaningful to you and to nobody else. To carry it to another machine, push a seed instead — the root files alone — and let
initrebuild the members there.