Releases: wighawag/offshoot
Release list
offshoot@0.3.1
Patch Changes
-
90bdd7f: Pin the end-to-end scaffold in the jolly-roger equivalence suite to
PINNED_REF, like the rest of the suite.The file already pins the template to a fixed commit, and says why: upstream evolution must not be able to break this suite "and with it every publish from this monorepo". One case escaped the pin. Every other test works from the pre-fetched
templateDirresolved atPINNED_REF, but the acceptance case calledscaffold()with a barewighawag/jolly-roger, which resolves livemain.So it broke exactly as predicted, from a commit in a different repository: on 2026-09-06 jolly-roger rewrote
home.e2e.tsto read the app's name fromweb-config.jsonrather than spelling it out, the literal the assertion looks for stopped existing, and the next publish from this monorepo failed on a template file nobody here had touched. Pinning that call restores the intended property, which is that this suite describes the template at one known commit and changes only when someone deliberately bumps the SHA.
offshoot-fanout@0.6.0
Minor Changes
-
de6667a:
clonenow produces a treefanoutcan actually run on, by creating a local tracking branch for every branch each repo's ownfanout.config.jsonnames.The verb promised "the tree comes back on a machine that has never seen it", and what came back could not run the tool's own primary verb.
git cloneleaves one local branch, so every other node of a repo was a remote-tracking ref the cascade cannot merge into. Measured on the real 11-repo tree,cloneinto an empty directory followed immediately byoffshoot-fanout --dry-runreported:✗ template-commit-reveal@with/pixi-js CONFLICT — conflict in 0 file(s) merging main ⊘ template-commit-reveal@with/all skipped — parent not updated (conflict) ⊘ reveal-or-die@main skipped — parent not updated (skipped)Nothing was wrong with the tree, and
conflict in 0 file(s)was a second bug on top of the first (fixed separately). The same clone now reports all 17 nodes up to date with no other setup.This is not a new read.
clonealready parses every repo's config off its config branch to discover the tree's edges, and.branchesis in the object it has just parsed, so the loop every consumer was re-implementing is now done once, where the data already is.Decisions worth knowing:
- On by default, with
--no-branchesfor a minimal clone. A tree that cannot runfanoutis broken rather than minimal. - A named branch origin does not have is reported and exits non-zero, per repo and in a summary. An existing clone is fetched first, so "not on origin" is a fact rather than a claim about a stale ref cache;
--dry-rundoes not write, so it labels its answer provisional instead. - The exit code follows one rule: non-zero when what came back is not what was asked for. That now also covers a repo that was skipped because its directory was taken, and a search the host itself truncated, both of which lose more than a branch does and both of which used to exit 0 in silence. A missing credential stays zero, because
--require-authalready exists to make that call. - An existing local branch is never moved, so re-running on a machine with work in progress is safe. The whole verb stays idempotent.
- The config branch is materialised too, and is the single exception to "never move a branch": it is fast-forwarded to origin on a re-run, because it holds no work, is never checked out, and a stale copy silently changes which nodes exist at all (a local
offshootoutranksorigin/offshootinresolveConfig). Divergence is reported, not resolved. Materialising it at all fixes a worse bug: without a localoffshoot,config stemfound no parent ref, committed a parentless config commit, announced "created", and printed a push command origin rejects as non-fast-forward. - Branches no config names are not created, including
tooling.branchesexists to keep scratch branches out of the cascade without naming them, andtoolingis a maintainer's local cache of the stem's orphan branch, in no config and unmergeable by construction, so rebuilding it would mean inventing an edge no config states. The help and README say so, with the one-liner (git fetch stem tooling:tooling). - The
stemremote is wired but not fetched. The cascade fetches a cross-repo edge from the parent's sibling clone, not from that remote, so a fetch per repo would buy nothing. Also stated rather than left to be discovered.
- On by default, with
Patch Changes
-
de6667a:
cloneno longer rebuilds the wrong tree, or writes into a repo it was not pointed at.Three failure modes found while reviewing the verb as a whole, all of which reported success:
- The family was identified from whatever branch happened to be checked out. The root commit probe was
rev-list --max-parents=0 HEAD. A maintainer of a tree like this keeps orphan branches (the config branch; atoolingbranch), and with one of those checked out the probe returns the orphan's root commit, the host search matches only repos carrying that hash, andclonereports a one-repo family as if it were the tree. It now probes the default branch (origin/HEAD, elsemain), and says which ref it used when the probe fails. - A same-named repo at the root path was probed without question.
~/dev/template-commit-revealbelonging to something else was used to identify the family, so the entire reconstruction was of a different tree, reported with full confidence; the only hint was the root's ownskipped, exists with a different originline, buried underneath everything it had got wrong.clonenow refuses up front, naming both origins. - A directory nested inside a checkout was treated as a clone of the enclosing repo.
--is-inside-work-treeis true for any directory under a repo, so an emptytemplate-x/inside one was read as an existing clone: its enclosing repo'soriginwas inspected and, with no origin to disagree with, astemremote would be wired into the enclosing repo. Path checks now use a newisRepoRoot, which comparesrev-parse --show-toplevelagainst the path itself.
Also in
clone:owner/..parses as a repo name and resolved to the parent of--dir, now rejected; an existing root clone with nooriginno longer silently selects https (which defeats the private-member argument that makes ssh the default); andorigin/<branch>is read asrefs/remotes/origin/<branch>rather than as a DWIM rev that a legal local branch namedorigin/<branch>would win. A branch name from a third-party config that begins with-is rejected rather than passed to argv.The per-repo branch lines of the report moved out of the CLI into
formatBranchLinesinreport.ts, pure and exported. That seam was untested, and a dry run was announcing branches it would create in repos the same report had just said it would leave untouched, which is the "dry run contradicts the real run" failure the branch work exists to remove, reintroduced in the reporter. - The family was identified from whatever branch happened to be checked out. The root commit probe was
-
de6667a: A node whose branch does not exist locally is now an error that names the branch, instead of a conflict in zero files.
git merge-tree --write-treeexits 1 for two unrelated things: "merge completed, conflicts present", and "not something we can merge". The dry-run fast path read every exit 1 as the first, so a node at a branch that only exists as a remote-tracking ref reported✗ template-commit-reveal@with/pixi-js CONFLICT — conflict in 0 file(s) merging mainA zero-file conflict looks enough like a real merge result to send a diagnosis the wrong way, and it did: it was the visible symptom of
clonenot creating the branches its configs name, and it pointed at the merge rather than at the clone.Two things were wrong and both are fixed:
-
The dry run contradicted the real run. A real run reached
openWorkspaceand said the branch does not exist; only--dry-runandstatusclaimed a conflict. Since the entire purpose of those two is to predict a real run, the branch check now happens once, before either path, so they cannot disagree. The message says which branch is missing, whether origin has it, and how to create it:! template-commit-reveal@with/pixi-js error — no local branch `with/pixi-js` (named in offshoot:fanout.config.json), though `origin/with/pixi-js` exists. A remote-tracking ref cannot be merged into. Create it with `git branch --track with/pixi-js origin/with/pixi-js`, or re-run `offshoot-fanout clone`, which materialises every branch a config names -
Exit 1 is no longer assumed to mean "conflict". git prints the merged tree's OID as the first line of stdout for a conflict and nothing at all for a failure, and a real conflict always names at least one file, so the OID is the discriminator, exactly as the success path already required. Anything else falls back to the worktree path, which produces a real error rather than inventing a merge result.
Also fixed in the test harness:
setRemoteremoved and re-added the remote, which deletes its remote-tracking refs, so re-pointingoriginon a freshly cloned fixture silently discarded everyrefs/remotes/origin/*the clone had just fetched. It now usesset-urlwhen the remote exists, which makes cloned fixtures actually resemble a clone. -
offshoot-fanout@0.5.1
Patch Changes
-
90bdd7f: Fix three things
clonegot wrong the first time it was pointed at a real tree.It cloned the whole family when asked for a subtree. Every member shares the root commit, so discovery from any repo sees all of them; classification then marked each annotated repo a member regardless of whether it descended from the REQUESTED root. The printed tree was correctly scoped while the cloning was not, so
clone wighawag/template-svelte-tailwind-blogdrew a three-repo tree and put eleven repos on disk. Cloning is now restricted to repos reachable from the requested root by followingstemedges, and the family members outside that subtree are listed as such rather than fetched. The walk goes down one generation at a time, so a cycle from a mis-annotated repo terminates instead of spinning.It defaulted to https, which cannot fetch what discovery can see. Discovery is authenticated and therefore finds private members, but an https clone of a private repo fails asking for a username no non-interactive run can supply: a private member was discovered, listed in the tree, and failed while its ten public siblings succeeded. The default is now ssh, or
gh's configuredgit_protocolwhen it says https, so the credential that can see the tree can also fetch it.--prefer-httpsforces the old behaviour.config stemechoed the wrong value.--from-remotereports what it wrote, but printed the raw clone URL it read rather than the canonical id that lands in the file, so the confirmation described something other than the change. It now reports the recorded value, in the message and in the commit subject.Also reports the root as
clonedrather thanexistingwhen the run itself created it.
offshoot-fanout@0.4.1
Patch Changes
- 27b333f: Report a deliberately ignored node as
ignoredeven when an ancestor failed. An exclusion (registryignoreor--ignore) was tested after blocking, so a node under a conflicting parent printedskipped — parent not updated (conflict): word for word what a node that IS part of the cascade prints while waiting for the conflict to be fixed. A maintainer reading that would conclude the exclusion was not in force. Exclusion is a property of the node itself, so it is now decided first;skippedis said only about a node that would otherwise have been merged.statusshares the same path and now counts such nodes underignoredrather thanblocked.
offshoot-fanout@0.4.0
Minor Changes
-
f6446a6: Let a child repo name which branch of its parent feeds it, with
stemBranch.A cross-repo edge has a branch at both ends. The child already declared which of
its branches receives an update; it could not say which branch of the parent
sends it, so every child hung off the parent's primary and a repo built on a
variant of its parent was wired to the wrong parent by construction.{"branches": {"main": {"stemBranch": "with/local-signer"}}}Measured on a live tree: a site built on
with/local-signerand fed frommain
reported 14 conflicted files where its real parent gives 4, and the ten extra
were exactly the files that differ between the two branches. That is worse than
noise, because the ordinary resolution of those ten silently reverts the site off
the variant it is built on, in files that still compile, and a large conflict
count in a repo that is behind reads as ordinary drift.stemstill means a branch in the same repo, and setting both on one branch is
an error. AstemBranchnaming a branch the parent does not participate with is
reported and the node is left out, rather than quietly falling back to the
primary: a node missing from a report is the failure nobody investigates.
offshoot-fanout@0.3.1
Patch Changes
-
1b80efd: Skill: a push step, because cascading and publishing are not the same act.
The
reconcile-template-treeskill ended at "verify" and never said to push, which left the last step to habit. The habit isgit push origin mainper repo, and that silently strands every multi-branch node: the cascade merges into(repo, branch)pairs, so a repo holding variant branches keeps those merges local while--dry-rungoes on reportingup to date— correctly, since it compares local refs. Observed in a real run: every node up to date while one repo held +21, +21 and +25 unpushed merges on three variant branches.The new §7 pushes per node, filtered twice: skip branches with no upstream, and skip branches that do not contain the commit landed at the home. The second filter is the important one — a cascade is no reason to publish an unrelated work-in-progress branch that merely happens to be ahead, and "it was ahead" is not consent. It also states that cascade pushes are fast-forwards, and that a rejection means the remote moved and the merge was computed against a stale parent, so it needs redoing rather than
--force.Includes the
@{upstream}trap found while testing the snippet: inside a loop over branches, a bare@{upstream}resolves against the repo's current HEAD rather than the branch being examined, so fully-pushed branches report large fabricated counts — the exact symptom §7 is about, which sends a reader chasing it twice.§4now says "no push required to cascade" and points at §7, and the report asks for push state per node, naming any branch deliberately left unpushed and why.
offshoot@0.3.0
Minor Changes
-
715f2ab:
offshoot add [<feature>]: adopt an optional feature a template ships as a branch.A template that offers optional features publishes them as branches, and publishes the combinations it supports as branches too (
with/all). Adding one is therefore not a new merge mechanism: it is a switch to the branch carrying what the project already has, plus that feature, which is the variant switchoffshoot update --refhas always done. Alladddoes is find that branch.offshoot add messaging # resolves to: offshoot update --ref with/messaging offshoot add # no argument: list what can be added offshoot add sync --dry-run # resolve the target, change nothing
- The feature sets come from the stem graph, not from branch names. offshoot reads
fanout.config.jsonon the template's config branch (defaultoffshoot,--config-branchto override), the same graphoffshoot-fanoutalready uses, so the maintainer declares it once and the template's working tree still carries no offshoot file. - A branch is adoptable only if it says so, with a new opt-in
"feature": true(whichoffshoot-fanoutignores). The graph is the maintainer's cascade graph and most of what is in it is not a feature: awebsitebranch, a docs branch, an integration branch that only combines others. A branch carries every declared feature among itself and everything it stems from, so a prerequisite comes along on its own (add messagingfrommainalso bringswith/local-signer), an integration branch carries exactly the union of its stems and stays offered as a reachable combination, and the base cannot be marked at all. - No naming convention is baked in. The argument is matched against the graph: exact branch name first, then a unique last path segment, so
messagingfindswith/messagingbut neverwith/foo-messaging. Ambiguity is an error listing the candidates, never a guess, andtrackalways records the full branch name. A template can name its branches anything. - A combination the template does not publish is refused, naming the branches that do carry it (
with/all (also brings with/hosted-account)). The target of anaddis always a real branch someone built and tested, so a project is never handed a combination nobody has ever run; a refusal that keeps coming up is the signal for the template to publish that integration branch.
Also fixed:
offshoot update --ref <branch>re-pointstrackwhen the new branch is at the same commit as the current one (a renamed branch, or a feature branch that has not diverged yet). It used to report "already up to date" and leavetrackpointing at a branch that may be about to disappear. - The feature sets come from the stem graph, not from branch names. offshoot reads
create-offshoot@0.1.2
Patch Changes
- Updated dependencies [715f2ab]
- offshoot@0.3.0
offshoot-fanout@0.3.0
Minor Changes
-
539a9ca: A branch can now declare several stems, so an integration branch that combines independent extensions is expressible:
"extended/complete": {"stem": ["extended/hosted-account", "extended/local-signer"]}.stemstill accepts a plain string, so every existing config keeps working.The alternative was chaining, which says something different and usually wrong: that one extension is built on the other, so each inherits the previous one's work.
The node graph becomes a DAG, which changes three things:
- Order. Traversal is now a topological sweep instead of a walk: an integration node is processed only once every stem is done, so it can never be merged against one stem's stale state (the failure this project exists to prevent). Anything the sweep cannot reach is a stem cycle, and is now reported as an error rather than silently dropped, which the old visited-set walk did.
- Merging. Stems are merged one at a time, in the order the config lists them, so each gets its own conflict and its own chance at
--leave-conflicts. If any stem fails, the node isskippedrather than merged from the stems that worked. If a later stem conflicts, the earlier merges are already committed and are kept: the message names both what landed and what blocked, and re-running continues from there. - Reporting. A node with several stems is rendered in full under its first stem and cross-linked under the others (
↳ … also merges from here; shown under …). It is counted once, insummarizeand instatus.
Drift compares an integration node against all of its stems at once (
git log <branch> --not <stem1> <stem2>), so it is not reported as drifting from what it just merged.--dry-runpredicts each stem against the branch as it stands, sincegit merge-treeneeds a commit and there is no commit for "the branch after stem 1 merged": exact for a single-stem node, approximate for the stems after the first, and the message says so.
offshoot-fanout@0.2.0
Minor Changes
-
dd58951: Branch-aware fanout: the unit of work is now a
(repo, branch)node, not a repo.- The destination branch is controlled and reported. Every report line is
repo@branch. A change no longer lands on "whatever the child happened to have checked out" while the report saysmerged. Repos with no config keep today's behaviour exactly: one node atmain, or the checked-out branch when there is nomain(the report says which was chosen).--branchstill works as a global override. - In-repo topology. A branch can declare another branch of the same repo as its stem, so
shadcn@main→jolly-roger@main→jolly-roger@variant/fullcascades in one pass, in that order. BFS over nodes gives the ordering; a failed node still marks its descendantsskipped. - Merging into a branch that is not checked out. Never
git checkout. The merge happens in place when the target branch is checked out, otherwise in a temporary linked worktree that is removed afterwards, or kept (with its path reported) when--leave-conflictsleaves a conflict in it. A dirty tree now only blocks the branch that is actually checked out. - Config on an orphan branch. Per-repo config lives on a branch (default
offshoot) infanout.config.json, read withgit show(with anorigin/<branch>fallback) and never checked out, so a template carries no offshoot file in its working tree.branchesis opt-in and keeps scratch branches out of the cascade without naming them;verifyis a command run in merged nodes, only ever behind the new--verifyflag, and a failed one keeps its temporary worktree so the failure can be reproduced. Newoffshoot-fanout config show|set, plus--config-branchand--no-config. Absent config means the defaults, so a repo that matches them stays free of offshoot references; a branch of that name holding no config file is treated as a name collision rather than an error, since only a config that is present and unparseable should stop a cascade. - Linked worktrees are never repos. Detected via
--git-dirvs--git-common-dir, always skipped, and mentioned once as a worktree of their repo. This removes the permanent "refusing to merge unrelated histories" false alarm agit worktree addsibling produced. - Explicit ignores.
--ignore <path-or-name>(repeatable) plus anignorearray in the registry, whichdiscover --savenow preserves and merges into rather than clobbering. Ignored and skipped nodes stay visible in the report. --dry-runandstatusare now genuinely read-only: they compute merges in memory withgit merge-treeinstead of merging and aborting. They still report a dirty branch asdirty, so a dry-run never promises a merge the real run refuses.
- The destination branch is controlled and reported. Every report line is