Skip to content

Releases: wighawag/offshoot

offshoot@0.3.1

Choose a tag to compare

@github-actions github-actions released this 12 Sep 07:11
0258ecb

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 templateDir resolved at PINNED_REF, but the acceptance case called scaffold() with a bare wighawag/jolly-roger, which resolves live main.

    So it broke exactly as predicted, from a commit in a different repository: on 2026-09-06 jolly-roger rewrote home.e2e.ts to read the app's name from web-config.json rather 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

Choose a tag to compare

@github-actions github-actions released this 12 Sep 10:45
3f7a4bc

Minor Changes

  • de6667a: clone now produces a tree fanout can actually run on, by creating a local tracking branch for every branch each repo's own fanout.config.json names.

    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 clone leaves 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, clone into an empty directory followed immediately by offshoot-fanout --dry-run reported:

    ✗ 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. clone already parses every repo's config off its config branch to discover the tree's edges, and .branches is 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-branches for a minimal clone. A tree that cannot run fanout is 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-run does 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-auth already 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 offshoot outranks origin/offshoot in resolveConfig). Divergence is reported, not resolved. Materialising it at all fixes a worse bug: without a local offshoot, config stem found 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. branches exists to keep scratch branches out of the cascade without naming them, and tooling is 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 stem remote 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.

Patch Changes

  • de6667a: clone no 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; a tooling branch), and with one of those checked out the probe returns the orphan's root commit, the host search matches only repos carrying that hash, and clone reports a one-repo family as if it were the tree. It now probes the default branch (origin/HEAD, else main), 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-reveal belonging 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 own skipped, exists with a different origin line, buried underneath everything it had got wrong. clone now refuses up front, naming both origins.
    • A directory nested inside a checkout was treated as a clone of the enclosing repo. --is-inside-work-tree is true for any directory under a repo, so an empty template-x/ inside one was read as an existing clone: its enclosing repo's origin was inspected and, with no origin to disagree with, a stem remote would be wired into the enclosing repo. Path checks now use a new isRepoRoot, which compares rev-parse --show-toplevel against 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 no origin no longer silently selects https (which defeats the private-member argument that makes ssh the default); and origin/<branch> is read as refs/remotes/origin/<branch> rather than as a DWIM rev that a legal local branch named origin/<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 formatBranchLines in report.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.

  • 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-tree exits 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 main
    

    A 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 clone not 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 openWorkspace and said the branch does not exist; only --dry-run and status claimed 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: setRemote removed and re-added the remote, which deletes its remote-tracking refs, so re-pointing origin on a freshly cloned fixture silently discarded every refs/remotes/origin/* the clone had just fetched. It now uses set-url when the remote exists, which makes cloned fixtures actually resemble a clone.

offshoot-fanout@0.5.1

Choose a tag to compare

@github-actions github-actions released this 12 Sep 07:11
0258ecb

Patch Changes

  • 90bdd7f: Fix three things clone got 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-blog drew a three-repo tree and put eleven repos on disk. Cloning is now restricted to repos reachable from the requested root by following stem edges, 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 configured git_protocol when it says https, so the credential that can see the tree can also fetch it. --prefer-https forces the old behaviour.

    config stem echoed the wrong value. --from-remote reports 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 cloned rather than existing when the run itself created it.

offshoot-fanout@0.4.1

Choose a tag to compare

@github-actions github-actions released this 30 Aug 16:06
f8d0343

Patch Changes

  • 27b333f: Report a deliberately ignored node as ignored even when an ancestor failed. An exclusion (registry ignore or --ignore) was tested after blocking, so a node under a conflicting parent printed skipped — 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; skipped is said only about a node that would otherwise have been merged. status shares the same path and now counts such nodes under ignored rather than blocked.

offshoot-fanout@0.4.0

Choose a tag to compare

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-signer and fed from main
    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.

    stem still means a branch in the same repo, and setting both on one branch is
    an error. A stemBranch naming 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

Choose a tag to compare

@github-actions github-actions released this 18 Aug 15:27
80079ca

Patch Changes

  • 1b80efd: Skill: a push step, because cascading and publishing are not the same act.

    The reconcile-template-tree skill ended at "verify" and never said to push, which left the last step to habit. The habit is git push origin main per 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-run goes on reporting up 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.

    §4 now 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

Choose a tag to compare

@github-actions github-actions released this 17 Aug 12:52
851fade

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 switch offshoot update --ref has always done. All add does 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.json on the template's config branch (default offshoot, --config-branch to override), the same graph offshoot-fanout already 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 (which offshoot-fanout ignores). The graph is the maintainer's cascade graph and most of what is in it is not a feature: a website branch, 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 messaging from main also brings with/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 messaging finds with/messaging but never with/foo-messaging. Ambiguity is an error listing the candidates, never a guess, and track always 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 an add is 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-points track when 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 leave track pointing at a branch that may be about to disappear.

create-offshoot@0.1.2

Choose a tag to compare

@github-actions github-actions released this 17 Aug 12:52
851fade

Patch Changes

  • Updated dependencies [715f2ab]
    • offshoot@0.3.0

offshoot-fanout@0.3.0

Choose a tag to compare

@github-actions github-actions released this 16 Aug 07:49
aa5385b

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"]}. stem still 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 is skipped rather 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, in summarize and in status.

    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-run predicts each stem against the branch as it stands, since git merge-tree needs 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

Choose a tag to compare

@github-actions github-actions released this 15 Aug 11:22
2a4bcd6

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 says merged. Repos with no config keep today's behaviour exactly: one node at main, or the checked-out branch when there is no main (the report says which was chosen). --branch still works as a global override.
    • In-repo topology. A branch can declare another branch of the same repo as its stem, so shadcn@mainjolly-roger@mainjolly-roger@variant/full cascades in one pass, in that order. BFS over nodes gives the ordering; a failed node still marks its descendants skipped.
    • 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-conflicts leaves 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) in fanout.config.json, read with git show (with an origin/<branch> fallback) and never checked out, so a template carries no offshoot file in its working tree. branches is opt-in and keeps scratch branches out of the cascade without naming them; verify is a command run in merged nodes, only ever behind the new --verify flag, and a failed one keeps its temporary worktree so the failure can be reproduced. New offshoot-fanout config show|set, plus --config-branch and --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-dir vs --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 a git worktree add sibling produced.
    • Explicit ignores. --ignore <path-or-name> (repeatable) plus an ignore array in the registry, which discover --save now preserves and merges into rather than clobbering. Ignored and skipped nodes stay visible in the report.
    • --dry-run and status are now genuinely read-only: they compute merges in memory with git merge-tree instead of merging and aborting. They still report a dirty branch as dirty, so a dry-run never promises a merge the real run refuses.