Artifact 17b9945b2bbffc37fc4c9779174fd2add976ef0ecaf76806f55de05d50a1b2dc:
- File CHANGELOG.md — part of check-in [53f9af98e3] at 2026-10-22 15:27:40 on branch feat/horizon-cones — docs(changelog): the horizon seed (user: [email protected], size: 161028) [annotate] [blame] [check-ins using]
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Added
- Content horizons in the public API and CLI. A horizon is part of a
store
Cone:NewHorizonCone(include, exclude, Horizon{Checkin, Rule})builds one, with or without path members, andCone.HorizonandCone.WithHorizonread and change it.Repo.SetStoreConeandRepo.SetStoreConeContextset, deepen or drop it, deepening fetching what the older horizon keeps, and the newCloneOpts.StoreConeclones straight into a cone or horizon store. A checkout cone refuses a horizon withErrHorizonCheckout, since a horizon governs what the repository holds, not which paths a checkout writes.libfossil repo clonetakes--horizon <full hash>,--leaves,--coneand--cone-not, andlibfossil repo cone storetakes--horizon,--leavesand--no-horizonand shows the horizon. The structural horizon is not exposed. - A cone can carry a horizon (draft-fossil-horizon-00 §3): a check-in
named by its full hash, the held-set rule (
descendantsorleaves) and the depth of the cut (contentorstructural), with or without path members. A cone with a horizon serializes as version 3, byte for byte as zit writes it, so its digest matches zit's; an implementation that predates horizons refuses it. - The held set of a horizon (draft-fossil-horizon-00 §3.1): the check-ins
whose content a horizon keeps, computed from the check-in graph the
repository holds and stored nowhere. Under
descendantsit is C and every check-in descended from it through any parent, merges included; underleavesit adds every descendant of each check-in where a childless check-in's ancestry meets C's. Childless is a property of the P cards alone, open or closed: a branch tip merged elsewhere has a child and adds nothing, unlike fossil's same-branch leaf. Both rules give a set closed under descendants. The walk is bounded and fails with an error rather than run unbounded. - A store cone can carry a content horizon (draft-fossil-horizon-00 §4, §6.3). A blob is elided when no check-in in the held set names it anywhere in its tree, and with path members too a blob is kept only in both keep sets; structure is never elided under a horizon, and a horizon the store cannot place yet elides nothing. Against any peer, a horizon store requests nothing below the horizon, records below-horizon blobs it is sent as elided instead of storing them, holds and requests no cluster, and fetches a below-horizon blob an operation needs on demand. Moving C earlier and pulling fetches what the deeper horizon keeps. A structural horizon is refused for now. Against a stock Fossil origin a new check-in announced only inside a cluster is not learned until a later check-in names it; the draft now has the clone request a horizon-ignorant origin's cluster members as transit (zit#266), which lands with the horizon pragmas.
- Horizon serving (draft-fossil-horizon-00 §6.1, §6.2, §6.2.1, §6.2.5,
§6.4). A horizon store's clone and pull requests carry
pragma horizon <hash> <rule>. An origin computes the held set over its own graph, sends every structural artifact and only the blobs above the horizon (and inside any path cone), sends a delta in full when its base is not kept, sends no cluster, and answerspragma horizon-served content;CloneResultandSyncResultgainHorizonServed. A server over a horizon store refuses a dense clone, and a horizon request whose held set is not a subset of its own, with the push card ahead of the error so fossil 2.28 aborts. Against an origin that does not answerhorizon-served, a horizon clone requests the phantoms its clusters name, as transit, and classifies what arrives, so a new check-in stock Fossil announces only inside a cluster still arrives (zit#266). Interoperates with zit atbbd1435674in both directions. - The horizon cursor (draft-fossil-horizon-00 §6.2.6). An origin that
served a request at a horizon answers
pragma horizon-cursor <n>, its position in its own artifact sequence; a client that converged records it per origin and cone, and sends it back. The origin then announces only what lies above it, and the client sends no igot on a pull and only what it appended since on a push, so a pull from a large horizon store no longer re-announces everything either side holds. A reply to a push-only request announces nothing, gimmes are answered whatever the cursor, and an artifact arriving that a held one already cited voids older cursors once. Syncs over the HTTP transport key the cursor by the URL without its password; other transports keep none and announce in full. Clone replies carry no cursor yet. Interoperates with zit in both directions. - The structural horizon's seed (draft-fossil-horizon-00 §5.2), without its wire yet. An origin mints a control artifact whose T cards apply the tags in force at every entry of the held set (C, and each check-in of K whose primary parent lies outside it), dated at the latest tag event it summarizes rounded up to a whole second, so it is byte-identical to the seed zit mints. A clone holds the seed in its own table, outside the artifact store, where no sync, clone or rebuild can send it, and applies it to the derivation, so every check-in in K has the branch, tag and closed state it has densely. Replacing or discarding a seed re-derives the store. Seed signing is not implemented: go-libfossil signs no artifact.
Fixed
- A sparse store of a real-sized repository classifies its absent artifacts. v0.14.0 read, inflated and expanded every blob in the store, file content included, to find the manifests among them, under a whole-store budget of 64 MiB stored and 128 MiB expanded; a path-cone store of fossil-scm.org's own repository (67,894 artifacts, 8.4 GB expanded) failed at once with "byte budget exceeded", so its pull, verify and compaction failed too. Classification now never reads file content, answers completely linked check-ins from the crosslink tables, and parses raw cards only for what crosslinking does not record: roots, partly linked check-ins, sub-manifest trees and artifacts of other kinds. The same store classifies in about 2 s at a 65 MiB peak. One answer narrows: a cherry-pick source the repository has never held no longer counts as required content. fossil records no row and no phantom for it and never fetches it, so compaction no longer refuses over it.
Changed
- The horizon draft is re-pinned to zit's
horizon-rfccheck-in9bf7ebfe0f:bbd1435674, the revision its author calls ready to implement against, plus the zit#266 correction to §4.5, under which a clone requests the phantoms a horizon-ignorant origin's clusters name. Since the last pin it adds thehorizon-cursorpragma and the origin's classification-memoization SHOULDs (§6.1, §6.2, §6.2.6), restates the leaves rule over P cards with K closed under descendants (§3.1, §3.4), has the seed carry T cards for every entry of K (§5.2.1), and confines the cursor's announcements to replies to requests that pull (§6.2.6). The requirement table reads these as six new rows and updated ones: 51 requirements, of which the cone's horizon member and the held set are implemented.
[0.14.0] - 2026-10-21
Added
- Sparse stores (draft-fossil-sparse-cone-00 §5, Appendix A step 3). The
new
Repo.SetStoreConeandRepo.StoreConemethods set and read a store cone, kept in astoreconetable that exists only while a cone is set. An artifact the store lacks is now classified as a transit phantom, a shunned artifact or an elided phantom, by zit's rule: a blob is elided when F cards name it only outside the cone and no held delta uses it as its base. Elision is computed from the cone and never stored. Changing the cone reclassifies; losing it, or a cone this version cannot read, makes every elided phantom a transit phantom, and the store heals toward dense.fossil rebuilddrops the table, so it heals there too. Sync never requests an elided phantom, as a client or as a server.verify.ReportgainsPhantoms(transit) andElided. Elision is never an issue, so a store complete within its cone verifies OK, andRebuildkeeps the cone. A commit over a sparse store has no R card and carries the files the store lacks by hash.VerifyRCardskips over a sparse store, and skips an absent R card. A checkout opens over a sparse store when its own cone stays inside the store's. Manifests, sub-manifest nodes and control artifacts are always held, as zit holds them. Cone-scoped transfer (Appendix A step 4):Repo.CommitContext,Checkout.CheckinContext,Checkout.UpdateContextandCheckout.SetConeContexttake aRequiredContentOptsnaming a peer and recover the sub-manifest closure a sparse store lacks for a commit, update or widening through an authenticated gimme exchange: in one transaction, never widening the store cone, refusing incomplete, corrupt, shunned, unauthorized-private or wrong-project content, and never fetching an outside F payload.repo ciandrepo cone add|remove|settake--remote,--sync-user,--sync-pass,--sync-privateand--timeout(default 10m); the peer defaults to the saved last-sync-url and no password is saved. A cone-aware server serves a cone-scoped clone check-in by check-in: each check-in's row walks its tree, descending only the nodes whose range meets the cone and that its first parent's tree does not already hold, under the depth and visited-node bounds of single-name lookup (§8); file and sub-manifest rows go out only through those walks, a delta whose base the round did not reach goes out in full, and a round stops where a dense round would and resumes from its cookie, a walk too large for one round included. The round's work is proportional to its batch, not to the store. A gimme in a cone request is answered densely, by name (§6.4). A pull's inventory still announces every held row, as zit's does. A held blob that no F card names is not sent in a sparse clone; zit sends it. A server serves a store it holds sparsely only when its newpartial-mirrorsetting (SettingPartialMirror,repo config set partial-mirror on) says so; otherwise it refuses, as §5.3 requires.Repo.SetStoreConeContextandrepo cone store [PATH... | --not | --dense]replace the store cone and pull what the widening covers from the peer, the saved last-sync-url by default, refusing with the cone unchanged when there is no peer.Repo.CompactStoreandrepo cone compactdrop the held blobs the store cone elides, as zit'spack --drop-elideddoes; every manifest and sub-manifest node stays held, interiors included, as sparse-cone §5.4 requires, and so does every node a sparse sync receives (§6.3). A server answering a cone request acknowledges the reply withpragma cone-served(§6.3), andSyncResultandCloneResultreportConeServed, the acknowledged replies, besideElided. Widening a checkout cone over a sparse store fetches the payloads the widening covers from the peer and materializes them; with no peer the widening is refused and nothing changes. The private checkout marker moves crash-safely: the move is recorded in vvar before it starts and completed on the next open, so a crash at any step leaves a checkout fossil and go-libfossil both read. Checking out verifies the manifest's R card as fossil does, warning through the newExtractOpts.Warn(repo openprints a WARNING line) and succeeding, skipped over a sparse store or withrepo-cksumoff. Stash saves with a merge pending, as fossil does: the merge parent stays recorded, pop leaves it in place, and the next commit carries both parents; a pop that would overwrite a file the stash does not hold is refused before anything is written. It used to refuse the save. Pop merges a stashed change onto a file that changed since, as fossil's does: three-way with fossil's conflict markers and-baseline,-originaland-mergefiles, a binary conflict left empty, and "WARNING: N merge conflicts" with the stash still dropped; it used to overwrite the file with the stashed bytes. Stash and revert restore a file's executable bit from the checked-in version, not from a merge. - Sparse checkouts (draft-fossil-sparse-cone-00 §3 and §4). A cone is a
set of paths, each a directory subtree or a single file, included or
excluded; the longest member covering a path decides.
NewConebuilds one, normalized and serialized byte for byte as zit does;CheckoutCreateOpts.Conemakes a checkout sparse, andCheckout.SetConewidens, narrows or drops it (libfossil repo open --cone/--cone-not,libfossil repo cone show|add|remove|set|dense|test). A sparse checkout materializes, scans and tracks only the covered files, over flat and tree manifests; adding, removing, renaming or reverting an uncovered path is refused withErrOutsideCone, naming the cone. A commit carries the uncovered files from its parent unchanged and is the same check-in a dense checkout makes of the same change.UpdateandMergeapply changes to uncovered files to the record without writing them, counted inUpdateResult.OutOfCone; the pending merge survivesUpdateas fossil's does, a partial commit leaves record-only changes pending, and a partial commit of a pending merge is refused, as fossil refuses it.Revertand undo restore record-only rows too, and an undo image carries the cone it was saved under and is refused under another. The checkout stays readable by fossil, which sees the uncovered files as missing and will not commit until they are restored. Newsparseconeandhorizonconformance suites pin both drafts; with zit installed, a test checks the serialized cones, the materialized files and commits in both directions against zit, and its recorded answers are replayed without it. - go-libfossil reads sub-manifest trees (draft-fossil-submanifests-00), the
check-in form zit writes. It parses the S card and
the SUBMANIFEST artifact type, and every reader of a check-in's file set
--
ListFiles,LookupFile, checkout, diff, annotate, crosslink, rebuild, verify and commit -- walks a tree through one enumeration function: depth first, at most 16 levels, never presenting a tree it cannot walk in part. A delta manifest may name a tree as its baseline. A tree check-in whose sub-manifest has not arrived is deferred, with phantoms for its missing references, and linked when they arrive;Verifyreports it as a missing reference, not damage. The newsubmanifestconformance suite covers 72 reader and writer requirements, the Appendix A vectors included, and a test reads trees zit wrote and, with zit installed, syncs with zit in both directions. - go-libfossil writes sub-manifest trees when a repository opts in. Emission
is off by default, and every commit stays byte-identical to before.
Repo.EnableSubmanifests(k), orlibfossil repo submanifests enable [--k N], turns it on. It keeps an existing pin of the partition parameter k and refuses a different one. With no pin it takes an explicit k from 1 to 12, or selects one as zit does: the draft's cost rule over the main-branch tip, refined by replaying recent history. It refuses, changing nothing, when no k partitions the file set. It returns the warning an operator must see, worded as zit's: peers that do not announce sub-manifest support are refused trees, trees cannot be retired, and the setting must not be enabled on a repository such a peer serves (§8.1).Repo.RetuneSubmanifests(k)(retune --k N) moves the pin for later commits,Repo.DisableSubmanifests()(disable) turns emission off again, leaving existing trees in place, andstatusshows the settings and whether the repository holds a tree. A commit then stores its leaves and interior sub-manifests, rebuilt in full and shared by content addressing, before a manifest that carries only S cards and never a B card. A stored k outside 1..12 falls back to flat manifests with a warning. Withsubmanifest-auto-retuneon, a commit re-pins k when another value costs more than 15% less. A commit made in a sync session whose server sentavoid-submanifestsstays flat. The construction reproduces every Appendix A vector, and a recorded set of zit check-ins byte for byte: 3,000 files at k = 1, 3 and 5, deep paths, and names with spaces, tabs, newlines and renames. Enabling pins the k zit pins on the same history. With zit installed, zit verifies and lists go-libfossil's trees, and verifies a tree go-libfossil commits on top of one of zit's. Sync follows zit for trees: a peer that reads them says so with
pragma send-submanifests, which go-libfossil sends in every request right afterclient-versionand in every reply, clone replies included, right afterserver-version, whatever the sub-manifest settings. Once a repository holds a tree, a tree manifest or sub-manifest is refused to a peer that has not sent the pragma, whatever version it announces, with the errorPeer does not accept sub-manifests., so no fossil release receives one. A server decides from the request it answers, a client from the replies of its session. An artifact stored as a delta of a withheld tree goes to such a peer as full content. A server whoseenable-submanifestsis off sendspragma avoid-submanifestsin the same leading run of every reply; it binds only a commit made inside the session, so commits, made outside any session, follow the repository's own setting, and a server stores a pushed tree whatever its setting. A clone asks once, in its cleanup round, forenable-submanifests,submanifest-kandsubmanifest-auto-retunewith zit'sreqconfig /config, applying all three or none; a pull asks for none. Enabling emission over a storedsubmanifest-koutside 1 to 12 is refused, naming it, unless an explicit k replaces it. The conformance suite pins the draft's 19 October 2026 revision, which applies the 2026-10-19 errata and settles its sub-manifest deviations. Tracked in #452.Changed
Breaking, for callers comparing values with
==:ExtractOptsgained theWarncallback and the CLI'sRepoOpenCmdgained theConeandConeNotlists, so neither struct is comparable any more. Every other change to the exported API is an addition.Tag propagation after a crosslink now starts only from the primary parents the batch wrote plink rows for, and from the new parents of a reparent, rather than from every primary parent in the repository. It runs in the transaction that wrote those rows, as fossil's crosslink propagates from a check-in's parent as it links it, so a link and its propagation commit or roll back together. The same transaction runs the reparent pass and applies the leaf rechecks it scheduled, as fossil's manifest_crosslink_end does: no committed link leaves scheduled rechecks pending. Neither a sweep nor a sync runs a redundant gate at its end, or a repair after a failed round. A rebuild links every check-in, so it propagates from every parent as before. Tracked in #432.
A sync that fails after earlier rounds' phantom fills linked artifacts no longer runs a crosslink sweep before it returns. The artifacts earlier rounds committed outside a phantom fill stay unlinked until the next crosslink (the next sync, clone or
rebuild), which links them. No tag or leaf state is lost: everything linked was repaired in its own transaction.The conformance suite no longer reads erratum files. zit applied every erratum in its draft text and retired
docs/rfcs/erratum/at check-in62a7d1bb3f; each draft now records its corrections in its own Changes appendix, and the draft text is the authority. The# erratumpins andconformance/rfcs/erratum/are gone. The artifact-format, repo-state, sync-protocol and delta-format drafts are unchanged. The sub-manifest draft is re-pinned with only its expiry date and a reflow changed. The sparse-cone draft is re-pinned to its 19 October 2026 revision on zit'shorizon-rfcbranch, where §3.6 is removed and §3.4 states the literal prefix rule once: a metacharacter in a member is a literal character, never expanded, and the member may be refused.SC-3.6-1is replaced bySC-3.4-3, which the same case covers.Sis no longer rejected as an undefined card letter, and a manifest with noDcard is reclassified as a sub-manifest before its cards are checked, so it is rejected for its first illegal card rather than for the missingDcard. The crosslink sweep's record of examined blobs is versioned; the first sweep after upgrading drops the old record and examines every blob again, once, so trees an earlier version could not parse are linked.The three-way merge behind
MergeandUpdateis a port of fossil 2.28'smerge3.cand the diff it runs on, and writes what fossil writes for the same three files, byte for byte. A conflict's markers carry fossil's(line N)numbers and include the suggested resolution fossil computes by merging the conflicting lines token by token; marker lines end in CRLF when both versions' first line does. Content is aligned as fossil's diff aligns it, so clean merges and conflict regions match too. As in fossil, a UTF-8 byte-order mark is dropped and UTF-16 text is merged as UTF-8, and a file fossil cannot diff as text (a NUL byte, or a line over 32767 bytes) is not merged: the local copy is kept and the merge reports a conflict.HasConflictMarkersnow skips\ras well as\nbetween lines, as fossil'scontains_merge_markerdoes.The conformance suite is re-pinned to zit check-in
68a3f76cb7, where the artifact-format, repo-state and sync-protocol drafts name themselves-00instead of-01throughout and their Changes appendices are headed "Changes in This Revision". No normative text changed and no section number moved, so the requirement tables and the coverage counts are unchanged; the pinned copies take zit's file names, and the comments and tables that cited the-01drafts cite the-00ones. The sparse-cone copy is re-pinned tod497c2432f, whose text is the same.
Fixed
Repo.Verify, andrepo verifywith it, no longer slows quadratically on long delta chains. It expanded every blob on its own, re-applying the blob's whole chain each time, which took 49 minutes on a clone of fossil-scm.org's own repository (chains averaging 532 deltas) wherefossil test-integritytakes six and a half. It now makes one sweep down the delta forest, applying each delta once: 19 seconds on the same clone.Adding a directory (
Checkout.Add,repo add DIR,repo add .) adds the files beneath it, as fossil's add does, skipping dotfiles found on the way; a dotfile named explicitly is still added. The directory itself used to be stored as a file, and the next commit failed reading it.ignore-globis not consulted yet.repo verifyruns the full verifier (blob hashes, delta chains, phantom records, derived tables) and prints what the store lacks by class: transit phantoms apart from artifacts the store cone elides, which are healthy (sparse-cone §5.2). It used to hash the held blobs only and print a fixed line, so neither damage outside the blobs nor elision showed.repo cloneremembers its source aslast-sync-url, asfossil clonedoes, keeping a user name and never a password. Nothing in go-libfossil wrote the setting, so the default peer forrepo cone store, widening and required-content recovery existed only in repositories fossil had cloned, and a go-libfossil clone needed--remoteevery time.repo statusreports the checkout's changes against the version it has checked out, throughCheckout.Status, with fossil's labels. Run from inside a checkout with the default--dir ., it reported every tracked file MISSING: the walk skipped its own root, named ".", as a hidden directory. It also compared the disk with tip, so a checkout of an older version read as edited, and it listed untracked files, whichfossil statusleaves tofossil extras.A sparse store that will not serve a clone now refuses it as fossil refuses an unauthorized clone: the push card naming the repository, then the error. fossil 2.28's clone client read the bare error as an empty, finished clone, exited 0 and left a repository with no project code. It now aborts with the refusal and leaves nothing, and go-libfossil's client re-asks once and stops at the second refusal, as fossil's does.
The simulation's safety invariants no longer read through armed fault injection.
CheckSafetyand the other checks ran while Buggify was still on, so read-path sites truncated or flipped the bytes the check itself expanded, and every seed at the adversarial and hostile levels reported a blob-integrity violation that was never in the database. Each check now suspends injection for its own reads with the newsimio.SuspendBuggify, which restores it with its random stream intact. Scenario fixtures are written the same way and fail the test if they do not go in; before, a store that injection failed during setup left an empty UUID, and the assertion that read it panicked. The cluster scenario generates its clusters after the run with injection suspended too.verify.Verifyaudits leaves by repo-state §§7.2–7.3's same-branch rule and branch-tag recheck schedule instead of assuming every check-in without children must be a leaf. Arrival-order retained verdicts are accepted; decidable stale, missing and stray rows are reported. Both the draft's branch reading and Fossil 2.28's literal-trunk reading must agree that a row is wrong, although their same-branch witnesses may differ. Parent tags replace P-card ancestry only when valid and active; retained ancestry after cancellation is checked against recorded parent-tag history. Parent-tag tokenization now follows Fossil's single-byte separators rather than collapsing whitespace; malformed lists retain their previous ancestry. Uppercase hashes and NUL-terminated values follow Fossil too. Leaf and parent-parser conformance cases run Verify on both implementations' tables. Tracked in #433.Checkout.Updateno longer merges a symlink: one edited locally and changed by the target is left as it is and listed inUpdateResult.Unmerged, as fossil's update reports "Cannot merge symlink", instead of writing conflict markers through the link into the file it points at. In a sparse checkout, a rename outside the cone no longer refuses the update: the rename moves the record, or writes the file when its new name is in the cone.Checkout.Updatesaves undo state as fossil'supdatedoes, and the newCheckout.UndoandCheckout.Redoreverse and replay it: the files the update wrote, merged or removed come back, with the file list, pending merges and the checked-out version (checkout-hashincluded). The undo tables now use fossil's schema (isExe, a uniquepathname, replayed inrowidorder), sofossil undoreverses a go-libfossil update andCheckout.Undoreversesfossil update; the executable bit saved is the file's own, as fossil reads it. Like fossil, the update saves a file merged into a renamed one under its old name only, and saves neither aNAME-originalbackup nor a change of executable bit alone. An update whose writes fail partway puts the files it changed back, as fossil'sundo_rollbackdoes. Under js/wasm, which has no undo, Update saves none. Tracked in #446.Checkout.Revertundoes a rename whose old name another file now holds: a swap, a chain (a.txttoc.txt, thenb.txttoa.txt), a new file added under the old name, or an untracked file there. It used to refuse. Like fossil'srevert, it now saves each file it writes or deletes for undo first, soCheckout.Undoorfossil undobrings back what it overwrote, and a revert that fails partway puts the files back. Every committed file ends under its committed name with its committed content, and the add at the old name is dropped, as fossil drops it. Fossil's own revert leaves a swap's files renamed and stops tracking the second file of a chain; go-libfossil reverts both. Revert now changes vfile in one transaction. Under js/wasm, which has no undo, it saves none. Tracked in #248.A public check-in makes every file blob it names public, as fossil's commit does. Content first stored privately, for example by syncing a private branch, stayed private when a public check-in reused it, so a peer without private access never received it and the check-in's file stayed a phantom there. Tracked in #439.
The checkout scan sets
vfile.chngedas fossil'svfile_check_signaturedoes in every case. A file missing from disk is marked changed, with mtime -1, and so is a removed file. A change to the executable bit is recorded as 6 (became executable) or 8 (lost it), and a change to or from a symlink as 7 or 9, and a mode change that is undone returns to 0. Symlinks follow fossil'sallow-symlinkssetting, off by default: off, a symlink is seen as what it points to; on, as itself, its content being its target's name. Status reports the mode changes as fossil'schangescommand does, with the new change typesexecutable,symlink,unexecandunlink(CheckoutChange.Change). Commit records the permission it finds on disk,lincluded, and a symlink's target as its content. As on Windows in fossil, permissions are not read where the storage has no file modes (MemStorage) or on Windows: the recorded ones are kept, where commit used to drop everyx.ScanENotFilenow refuses something other than a file where a tracked file belongs, as fossil's merge does, except at a pending add or removal, which fossil does not check. Extract and Update write a symlink entry as a symlink whileallow-symlinksis on and the storage has symlinks, and otherwise as a plain file holding the target's name, as fossil does. They delete a symlink before writing a file over it, and never change a symlink's mode. Withallow-symlinksoff, fossil reports such a fileUNLINK, and its checkout then refuses without--force; Extract does the same. Extract still writes a missing file without asking forForce, an unlinked symlink entry included, since nothing on disk is lost. That keeps Create followed by Extract working, as fossil's open does.Stash and undo read and write working files through the checkout's storage (
simio.Storage) instead of theospackage, so they work on aMemStoragecheckout and under simulation (tracked in #448). Their functions take a*simio.Env. Stash save records each reverted file's mtime on its now unchanged row, as fossil's revert does. Stash apply and pop add a stashed added file again, as fossil'sstash_applydoes, and mark a restored edit's row changed with the mtime it was written with, where fossil leaves the reverted row (unchanged, the revert's mtime) for its next scan to correct; fossil misses an edit of the same size whose write lands in the revert's second. Undo and redo restore vfile as fossil does, and record each written file's mtime on its restored row when that row's mtime matched the file when it was saved, the case where the row vouches for the content; otherwise the row is restored with mtime 0 and the next scan hashes the file.Stash save scans the checkout first, as fossil's
stash_createdoes, so an edit no scan has recorded yet is stashed too; before, it was left out, or the save failed with nothing to stash.stash.Savetakes the scanner, a*checkout.Checkout, as its first argument.Undo and redo no longer print to standard output, and neither does Update's rollback of a failed update.
Checkout.UndoandCheckout.Redoreturn anUndoResultlisting the files they restored, created and removed, in fossil's order, for the caller to show as fossil'sUNDO,NEWandDELETElines.verify.Rebuildandverify.Verifycheck blob integrity in time linear in the repository, as fossil's rebuild does. They expanded blob by blob through a bounded cache, so on a real repository, whose delta chains run hundreds of revisions deep, every evicted chain was walked again from its root: the blob check on the Fossil SCM repository ran for over 45 minutes. The newcontent.ExpandAllwalks each delta tree once from its root, expanding every blob from its parent's content, and reports for each blob what an expansion of it alone reports. On the same repository the blob check takes about 12 seconds and a wholeVerifyabout 40; the issues reported are unchanged.Checkout.Updatefollows renames between the checkout's version and the target, as fossil'supdatedoes (find_filename_changes). A file with local changes that the target renamed and changed is merged under the new name, and the old name is removed; before, the edit was left behind as an untracked file under the old name and the new name got the target's content. The rename path runs through the two versions' common ancestor, so a rename on the checkout's own branch is followed backwards. Two cases follow fossil rather than the issue's wish: when the target only renamed the file, the edit stays under the old name, untracked, and the new name is not written (it shows as missing; the file is listed inUpdateResult.Unmerged); and a local rename of a file the target edited is not followed: the target's file is written under the old name, and the renamed copy is removed if unedited or kept, untracked, if edited. Tracked in #445.Each arrival of content is recorded in
rcvfromas fossil records it: one row per check-in, tag, sync, clone, served request that stores something, and unversioned write, made when the first content arrives, with the time as a Julian day, the credited user, the login card's nonce on a server, and the peer's address (or#!fossil unversioned addfor a local unversioned write). Every blob and unversioned file stored names its row inrcvid, a phantom names none, and an applied deletion keeps the row'srcvid. A new repository no longer starts with a placeholder row that every blob pointed at; an existing repository keeps it, and new receipts follow it.HandleOpts.RemoteAddrpasses the client's address to a server that is notServeHTTPorXferHandler.The event rows of control, attachment and other non-check-in artifacts match the ones fossil derives. A tag's control artifact ('g') now carries fossil's comment byte for byte: each target introduced by an
Edit [hash|hash]:link, its changes ordered by target and tag name,sym-tags named as tags, a branch move linked to its timeline and the move's ownsym-changes left out, values HTML-escaped, and no leading space. Attachment comments link the attached artifact and the ticket or tech note as fossil 2.28 does, and a deleted attachment'ssrcis NULL, not empty.Repo.Tagand closing a branch write the 'g' row fossil'stag addwrites; they wrote none until the next rebuild or clone. Hash prefixes in these comments, and in ticket comments, follow thehash-digitssetting up to 64 digits, where they stopped at 40.Timelineshows a tag change with the new text, and lists a tag added through this library as soon as it is added, as fossil's timeline does.A check-in with no C card has a NULL event comment, as fossil stores it, not an empty one.
Event, plink, attachment and forum post times match, in every bit, the ones fossil 2.28 stores when it is built for the same platform. fossil writes these times as 16-digit SQL text and reads them back with its bundled SQLite 3.52.0, whose text-to-double conversion is not correctly rounded. go-libfossil parsed the text with a correctly rounded conversion, so about one time in 3,000 was one unit in the last place away from fossil's (13 rows of fossil's own repository); it now uses SQLite 3.52.0's conversion. fossil's printf also computes the text in C
long double, which is a 64-bit double on macOS arm64 and Windows but 80-bit on x86-64 Linux, so the 16th digit differs between platforms for about one time in five. go-libfossil now computes in the long double of the platform it runs on, and matches fossil 2.28 bit for bit on macOS arm64 and linux-x64. Times written on one platform and rebuilt by fossil on another can still differ in the last place, as two fossils do; never by a millisecond.
[0.13.0] - 2026-10-20
Added
- A checkout-free interface for apps that embed the binary, documented in
docs/site/content/docs/cli-embedding.mdwith its stable JSON shapes and exit statuses (0 success, 1 failure, 2 not found).repo snapshotcommits one file's bytes onto trunk, a no-op that prints the tip when they match;repo log --pathlists the check-ins that changed a path, asfossil finfodoes;repo cat --at VERSION PATHreads a file as a version holds it, asfossil cat -r;repo diff --json A B [PATH]diffs two versions. Concurrent snapshots from several processes build one linear trunk. Tracked in #451.
Fixed
repo newaccepts the repository path as-R, as the other embedding commands do (it had required the argument and exited 80), and a path whose parent directory does not exist is refused with exit 1 and a message naming the directory, where it had failed with a SQLite "out of memory" error. The behaviour is documented incli-embedding.md. Tracked in #451.repo log --path,repo cat --atandrepo diffmatch a PATH as fossil does: they drop a leading./, a trailing/and doubled or/./separators, and compare names with the repository'scase-sensitivesetting (fossil's default when unset: off on macOS and Windows, on elsewhere), sonotes/a.mdfindsNotes/A.mdwhen it is off. They had rejected both with exit 2 and compared case-sensitively. Not found stays exit 2, where fossil exits 1. Tracked in #451.- Opening a repository runs every pragma in a fixed order (
busy_timeout,journal_mode,locking_mode,synchronous,foreign_keys, then the rest by name), not onlybusy_timeoutfirst. Tracked in #451. - A stored Julian day converts to the millisecond SQLite's own date
functions give it,
(sqlite3_int64)(r*86400000.0+0.5)(fsltype.MillisFromJulianDay).db.ScanTimetruncated, so most times it returned were 1ms before the check-in's D card. - Opening a repository sets
busy_timeoutbefore any other pragma. The drivers ran the DSN's pragmas in map order, so an open while another process held a lock could fail at once with "database is locked".
Changed
- A file's history (
manifest.FileHistory) orders check-ins that share a millisecond by rid, newest first, and applies its limit after dropping rows that list the file unchanged. - Check-ins, tags and branches record their time to the millisecond, as
fossil and zit write it. The default clock time, which carried nanoseconds
that zit cannot read (danmestas/zit#244), is truncated, as fossil reads its
clock. A caller time with finer precision is rounded to the nearest
millisecond, as fossil rounds a
--date-override, except that a fraction past.999stays at.999rather than carrying into the next second, as in fossil. An exact half millisecond rounds up; fossil's result there depends on how its bundled SQLite prints the seconds (fossil 2.28 writes.124for.1245but.020for.0195), so it is not followed. - The conformance suite is re-pinned to the -01 drafts as zit check-in
3f86ff4261holds them, with six more errata applied. The artifact format says the Z card is last by the ordering and required-card rules, and §6.2 decides the Z verification state from the bytes at n-35 and n-34 (AF-6.2-7). §4.7.3 filters the named delta's delete markers out of a delta-of-delta's file set (AF-4.7.3-2), and §4.7.8 reads an absent and a present emptyJvalue alike (AF-4.7.8-1). In repo-state §5.3, a cancel keeps its card's value on the row it writes, while the event table and propagation treat it as null (RS-5.3-3, RS-5.3-6). An artifact's tag cards apply in card order (RS-5.3-7), so a branch root carrying*sym-NAMEand-sym-NAMEkeeps the tag (§6.2, RS-6.2-4). In sync-protocol §6.6, a client recognizes a refusal by its leading text (SP-6.6-4). go-libfossil already behaved this way; six requirements are new, two are reworded, and each has a case checked against fossil 2.28. - Checkouts record each file's mtime in
vfile.mtimeand scan the way fossil'svfile_check_signaturedoes: a file whose size matches its artifact and whose mtime matches the recorded one is taken as unchanged without being read, unlessScanHashis given or the repository'smtime-changessetting is off. A globalmtime-changesin~/.fossil, which fossil falls back to, is not read: go-libfossil reads no global settings. Status, commit, merge, revert and extract use this scan, as fossil's commands do; before, every scan hashed every file andvfile.mtimewas always 0, so fossil rehashed a go-libfossil checkout on every command too. Extract, update, revert, commit andSetMTimerecord mtimes where fossil does. One deliberate difference: fossil would miss an edit that keeps a file's size within the same second its mtime was recorded, in status and in commit; go-libfossil records such an mtime as 0 and rehashes the file instead, and fossil reading those rows rehashes it too. On the sqlite repository's 2,220-file checkout, status takes about 30 ms instead of about 250 ms.simio.MemStoragenow gives each write a new mtime, as a filesystem does.
Fixed
- Ticket artifacts link into a repository whose ticket schema lacks any of
the columns fossil treats as optional: the ticket table,
ticket.tkt_ctime, the ticketchng table,ticketchng.tkt_ridandticketchng.tkt_user. Crosslink always wrotetkt_userandtkt_ctime, so rebuilding fossil's own repository, which has notkt_user, failed. The ticket and ticketchng rows now follow fossil'sticket_insert: a change row is written only when it has a field or bookkeeping column to fill,tkt_useronly when the artifact names a user, a field stored in both tables is copied from the ticket row when the artifact leaves it alone, and abaseline for <field>column records the rid that last assigned the field. Without a ticket table nothing is derived for a ticket, including its timeline rows, as in fossil. Checkout.Updatekeeps uncommitted changes, as fossil'supdatedoes. It used to compare only committed versions, so it wrote the target's content over a locally edited file, deleted an edited file the target removed, and merged the current and target check-ins with their common ancestor while ignoring the working file. Visible changes:- Local edits are preserved. A locally edited file is merged three ways,
with the checkout's version as the base, the working file as the local
side and the target as the other; conflicts are written as markers and
listed in
Conflicted. - An update to another branch now takes the target's content for every file without local changes, as fossil does; it used to merge the two branches' committed changes, which could write conflict markers.
- An edited file the target deletes is kept, untracked; pending adds and
removals stay pending; a missing file is restored; a file the target
adds over an untracked one, read-only or not, is written after the
untracked one is copied to
NAME-original. - A binary file (a NUL byte, or a line over 32,767 bytes) is never
line-merged. Update saves the working copy as
NAME-originaland writes the target's content; Merge keeps the checkout's copy, whatever the file's strategy. Before, both spliced the versions line by line and reported a clean merge. - The new
UpdateResult.Unmergedlists files left to reconcile without markers: kept edited-but-deleted files, files added both locally and by the target, and binary conflicts. - Every file takes the target's executable bit, including a change to the bit alone. An update that could not finish (a directory where a file must be written, or a file or directory the process may not change) is refused before anything is written, the checkout database moves in one transaction, and a dry run changes nothing, the database included.
- Local edits are preserved. A locally edited file is merged three ways,
with the checkout's version as the base, the working file as the local
side and the target as the other; conflicts are written as markers and
listed in
Update saves no undo state yet, so unlike fossil's update it cannot be undone: a removed file restored from the target, or a working copy merged or replaced, cannot be brought back with undo. Files are matched by name: a file the target renamed is not followed, as fossil follows it. Tracked in #443.
config.mtimeanduser.mtimehold seconds since 1970, as fossil'snow()writes them.SetConfig,SetHashPolicyandCreateUserwrote a Julian day there, which fossil, comparing those times when it merges configuration, read as a moment in 1970.SetCapsandSetPasswordnow stamp the user's mtime too, asfossil user capabilitiesandfossil user passworddo. Opening a repository converts the Julian days already written there to seconds, or leaves them for a later open while another connection holds the write lock.- A sync login no longer reads
user.cexpire. That column is the expiry of fossil's web-login cookie, which fossil's own sync login ignores; reading it refused any user who had logged into or out of fossil's web UI, or whose cookie had lapsed (#441).repo invite --ttlkeeps its expiry in a config row of its own,invite-expire:LOGIN, in UTC, and a sync login checks that instead. Creating or deleting a user clears that row. The expiry used to be written in local time and read as UTC, so it was off by the local offset. SetMTimeon a checkout's extract and update now does what fossil's--setmtimedoes: each file takes the time of the newest check-in in the version's primary ancestry that recorded its content, in whole seconds, and every unedited file gets it, not only the files the update wrote. A file with local edits is left alone. It used to give every written file the target check-in's time, to the millisecond.- A stash records its creation time as a Julian day and lists it as
YYYY-MM-DD HH:MM:SS, as fossil's stash does; it stored SQLite'sCURRENT_TIMESTAMPtext, which the drivers handed back in different forms, and listed a fossil-written time as a bare number. Stashes list oldest first, by creation time, asfossil stash lsorders them, rather than newest first by id. A bisect listing shows each check-in's date asfossil bisect lsdoes, instead of a raw Julian day. ResolveVersion("NAME:TIMESTAMP")bounds the search with SQLite'sjulianday()of the rounded-up time, as fossil does, rather than the text form of that time that event rows hold. On a millisecond where the two differ, a check-in at the bound itself resolved here but not in fossil.
[0.12.0] - 2026-10-13
Added
CloneOpts.PullPhantomsmakesClonefetch the artifacts its clone rounds left missing with a follow-up pull session, and fail if any are still missing after it, as every clone did before. That pull departs from sync-protocol-01 Section 8.2, which sends no clone, pull or push card afterclone_seqno 0, so it is off by default (tracked in #424).Repo.SetHashPolicysets the hash policy by its name, asfossil hash-policy NEW-POLICYdoes: sha1, auto, sha3, sha3-only or shun-sha1, matched case-sensitively and exactly (artifact-format-01 7.1). Any other string, a wrong-case or empty one included, is an error that leaves the policy unchanged. Asking for auto in a repository that already holds a SHA3-named artifact stores sha3 (7.3), as fossil does. New conformance cases drive each name, wrong-case names, unknown names and the empty name through it, so AF-7.1-2 is now covered rather than partial.Checkout.ResolveVersionresolves a version name the wayRepo.ResolveVersiondoes and also resolves the names relative to the checkout:current,prev/previous(its primary parent) andnext(a child, primary first, then the newest), as fossil does inside a checkout (repo-state-01 §8.3). A keyword that names nothing, such asprevat a root, falls through to the other forms.ckoutstays an ordinary name: it is a keyword only in the checkout-inclusive request,Checkout.ResolveWorking.Repo.ResolveVersionis unchanged. A checkout with no recorded version has no relative names, and every other form still resolves. Themergecommand resolves its version through the checkout, somerge prevandmerge nextwork.Checkout.ResolveWorkingis fossil's checkout-inclusive request (ci+, repo-state-01 §8.3) and returns aWorkingVersion. While the checkout has a version,ckoutnames the working files:Workingis set andRIDis the checkout's version. No pseudo-RID is exported. Every other name, andckoutin a checkout with no version, resolves asCheckout.ResolveVersiondoes, except that only check-ins can answer: the request is a check-in request, so a technote carrying the tag does not.ckoutis a keyword only as the whole name, sockout.androot:ckoutread it as an ordinary name.ckoutis a keyword only inci+(as the 2026-10-13 erratum has it, and fossil), soResolveVersionandResolveBranchStartread it as an ordinary name. Newrepostate.8.3cases run the request againstfossil test-name-to-id --type ci+:ckoutover a tag of that name, with and without a checkout version, andckoutas the tag under the untyped request (RS-8.3-4; tracked in #419).Repo.ResolveBranchStartis fossil's branch-type request: it resolves a name, then returns the first check-in of the branch the result is on (repo-state-01 §8.3). It follows the draft where fossil 2.28 differs: fossil walks back only from atag:NAMEor plain-name result, so a hash,tipor a date gives the named check-in itself there. As the draft and fossil both require, only check-ins can match, so a technote tagged with the branch name does not shadow the branch. Therepostate.8.3checkout and branch-type cases now run against these and pass (RS-8.3-4, RS-8.3-12; tracked in #386).Repo.CurrentTrunkreturns the current trunk of repo-state-01 §9: themain-branchsetting's name (trunkwhen unset) resolved asResolveVersionresolves it, as fossil does forupdateoutside a checkout andopenwith no version. Only check-ins can answer, so a technote tagged with the name does not.Repo.BranchOpenLeafnames one open leaf of a branch, the one with the greatest mtime, or reports the branch not open (repo-state-01 §9). It follows the draft where fossil 2.28 differs: fossil'sbranch infotakes whichever open leaf its query meets first. An empty name is an error, as inBranchTip.BranchTipis unchanged and still returns the newest check-in on the branch, leaf or not. RS-9-1 and RS-9-4 now have cases (repostate.9.current-trunk*,repostate.9.open-leaf-*) and are no longer out of scope.db.DB.WithBoundTxruns a function in one write transaction and hands it a DB bound to that transaction: every statement on it joins the transaction, and aWithTxnested on it is a savepoint there, which its error rolls back alone, instead of a second connection waiting for the write lock.WithTxon a bound DB behaves that way too; on any other DB it is unchanged. The sync passes of #418 run on it.SyncOpts.ShareLinksturns on share-links mode (sync-protocol-01 §9.5), fossil's--share-links. Off by default. A pulling session sendspragma req-linksin its first request, after the operation cards, unlessSyncOpts.ProjectCodenames another project, and keeps eachpragma link URL ARG MTIMEas configlink:URL= ARG: URL and ARG defossilized and otherwise verbatim, an existing link replaced only by a strictly newer MTIME. ARG is never parsed or sent on. A link with the wrong token count, a URL that is not HTTP(S) or an MTIME that is not a positive decimal is dropped, as is every link with the mode off. Two of these follow the draft where fossil 2.28 differs: fossil fails fatally on a link URL its parser rejects (a bad port, or a form that is neither a URL nor a local repository) instead of dropping the link, and it reads MTIME withstrtollbase 0, so it keeps0x10as 16 and12zas 12. Like fossil, a push-only session ignores the option. The server is unchanged: it emits no link, which meets the draft whether or not a request asks for links. Newsync.9.5client cases run againstfossil sync --share-linksas well, and SP-9.5-4 is now covered rather than partial, with two new requirements, SP-9.5-5 (which links a client keeps) and SP-9.5-6 (req-linksonly in share-links mode). Tracked in #425.
Changed
Clonenow ends atclone_seqno 0and its cleanup round, as sync-protocol-01 Section 8.2 requires (SP-8.2-5): by default it no longer runs a follow-up pull for the phantoms a clone leaves, and it succeeds with them in place, as fossil(1)'s clone does. A caller that relies on a complete clone setsCloneOpts.PullPhantoms. Only the tests of that pull set it: therepo clonecommand, the DST clone harness and the parity tests all clone as fossil(1) does. Thesync.8.2.clone-zero-phantomcase passes and is no longer a deviation (tracked in #424).- The sync server answers a client-sent
clone_seqnowitherror bad commandand goes on with the cards after it, as sync-protocol-01 Section 5.2 says for a reply-only card sent by a client (SP-5.2-6), and as fossil'spage_xferdoes, including aclone_seqnowhose argument is not decimal. The error quotes the line as received. It used to ignore the card. Thesync.5.2.client-clone-seqnocase passes and is no longer a deviation (tracked in #424). - The conformance suite is re-pinned to the -01 drafts as zit check-in
5a1b741b25d8holds them, with five more errata applied: repo-state §5.3 (an inherited row blocks an older direct tag, so the result follows arrival order), §7.3 (leaves are rechecked only on branch-tag events), §8.3 (ckoutis a keyword only forci+and only as the whole name), sync-protocol §9.4/§9.5 (pragma avoid-delta-manifestsanswers ci-lock) and §10.1 (a file whose content does not match its hash fails the round). zit now keeps each -01 text under the draft's -00 file name; the copies keep their -01 names andconformance/rfcs/README.mdrecords the zit path and check-in. The new errata are pinned. The requirement tables are re-read against every changed hunk: RS-5.3-1, RS-7.3-1, RS-7.3-3, RS-8.3-4, SP-9.4-1, SP-10.1-6 and SP-10.1-7 are reworded, and RS-5.3-5, SP-9.4-3, SP-9.5-7 and SP-10.1-8 are new. The §7.3 and §8.3 cases that were marked as deviations, because go-libfossil already followed fossil, now pass as written, and new cases cover arrival order for a date tag, an untagged chain, one-batch leaf derivation and a merge parent's recheck. - A pull that receives a
uvfilewhose content does not match its hash now records the error, commits the cards before it and evaluates the continuation rules, as sync-protocol-01 Section 10.1 and fossil(1) do (SP-10.1-7), where it used to end the session with an error. A file or cfile that does not match its hash still fails the round (tracked in #421). - The sync server answers every
pragma ci-lockthat passes its argument guard withpragma avoid-delta-manifestswhile the repository'sforbid-delta-manifestssetting is on, after anyci-lock-fail(sync-protocol-01 §9.4). The setting is read as fossil'sdb_get_booleanreads it, defaulting to on as the draft and fossil 2.29 do; fossil 2.28's server defaults it to off, so against 2.28 the cases that rely on the default record that difference. The client recognizes the pragma and, as §9.5 now says, changes nothing about its transfer; go-libfossil commits without an autosync, so no commit is pending when it arrives. - A file or cfile whose content does not match its hash, found when it
arrives or later in the reply, now fails the round on the client
(sync-protocol-01 §10.1): nothing the reply carried is committed, the
igots and files before it included, and no further round starts.
SyncandClonereturn the error as before. Content errors that leave the stored artifacts consistent, such as a malformed file card or an unversioned file with a wrong hash, still commit the prefix and continue. Thesync.10.1.integrity-error-*cases now check that, against fossil 2.28 as well, and the gap tracked in #421 is gone with the text it rested on. - The conformance suite now checks the -01 drafts of the artifact-format,
repo-state and sync-protocol specifications (the delta format stays on -00;
there is no -01). The -00 errata are folded into -01 and no longer pinned;
the six -01 errata are pinned for the fossil 2.28 behavior they record.
conformance/rfcs/README.mdrecords the zit check-in each copy came from. New cases cover the date tag's event time, the Julian-day grammar, a delta manifest naming a delta, dropped reserved F cards, the trailing-punctuation retry, truncated payloads,errors N, refused pushes and the private-content gimme answer. No library behavior changes. Where -01 departs from fossil 2.28, the cases follow the draft: a cancelled, unparsable or0date tag keeps the D-card time under repo-state-01 §4.2 step 6, where go-libfossil, like fossil, stores no time or a time of 0; those cases are marked as deviations. The draft non-conformances the re-pin exposes are tracked in #370, #371, #372, #373, #374 and #375. - More conformance cases for the artifact and delta suites: single-name
lookup in a delta manifest (8.4), a lowercase K and R card from the writer
(4.7.9, 6.3.4), date-only timestamps (4.6.4), mismatching delta checksums
beyond the first (3.4), and the 2^32 limit on delta sizes (3.1), checked
with an unread 4 GiB mapping (skipped where it cannot be mapped).
AF-4.7.9-1, AF-8.4-1, DF-3-2, DF-3.1-6 and DF-3.4-3 are now covered.
AF-6.3-5 stays uncovered (go-libfossil does not check the R card at commit,
tracked in #387). Artifact-format-01 Section 4.6.4 lets a reader accept the
time-only and now/subsec forms (a reader MAY accept forms 2 through 5), so
deck.Parsekeeps rejecting them while fossil(1) and the date-tag path (SQLite julianday) accept them. - The conformance suite covers eleven more sync-protocol-01 rows and part of
a twelfth: the peer-lacks gimme filter, the twentieth redirect, VT, FF and
server-side token runs, the SHA3 refusal left out of
errors N, the Clone, Private and Admin capability checks, the fossilizeduvfileNAME, the deferred clone configuration, the well-formeduvigotban, the private inventory under private sync, the private-igot continuation, the written and kept check-in lock, and a discardedpragma linkARG. Where fossil(1) 2.28 differs (it repeats a gimme for a hash the peer lacks, refuses an escapeduvfileNAME, and sends a well-formeduvigotpastmax-upload), the cases follow the draft and record fossil's behavior. SP-9.5-4 is completed bySyncOpts.ShareLinks(see Added), which stores a link. - Repo-state conformance cases now cover a misnamed artifact (RS-4.4-3), the
default branch and the
main-branchsetting (RS-6.1-2, RS-6.1-4), the active-tag rule for leaves and the checkout's branch (RS-6.4-1), a parent tag that arrives before its target (RS-4.2-8), and the leaf recheck of untagged check-ins (RS-7.3-3; a check-in with a parent but no branch tag is rechecked only as the parent of a branch-tagged one, as in fossil, which the 2026-10-13 erratum made the draft's text). Where fossil 2.28 differs (its rebuild links a misnamed artifact, and itsbranch currentandleaf_checkread cancelled branch rows), the cases follow the draft and record the difference. Checkout-relative names and branch-type requests (RS-8.3-4, RS-8.3-12) have cases tracked in #386. - A repo-state conformance case,
repostate.6.1.main-branch-leaf, pins the leaf table's reading of an untagged check-in as themain-branchsetting's branch (RS-6.1-4). fossil 2.28'sleaf.creads it as the literaltrunk, so the case records that difference. No library behavior changes.
Fixed
- The leaf table is maintained incrementally, as repo-state-01 §7.3
(RS-7.3-3) and fossil(1) do, instead of being recomputed at the end of
every crosslink batch. A check-in and its parents, merge parents
included, are rechecked only when a branch tag of any type is written to
it or tag propagation updates or deletes its branch row; crosslinking a
check-in, adding a plink edge or reparenting schedules nothing. The
rechecks run at the end of the batch: the closing repair of a sweep,
clone or sync session, the end of each phantom-fill cascade transaction,
and the end of a commit's or a tag's own transaction. A check-in
therefore keeps its verdict when an untagged child arrives in a later
batch (
repostate.7.3.recheck-untagged-childpasses and is no longer a gap, tracked in #426), a commit onto a parent whose branch row is a singleton leaves both verdicts as they were, and a branch tag added by a tag control artifact now rechecks its target and parents, which it did not before. A cancel or singleton that replaces a propagating branch row rechecks the descendants that row reached, even in a sweep that has not yet handed them the row, soverify.Rebuildand a sweep give the tablefossil rebuildgives (repostate.7.3.cancel-propagation-recheck).verify.Rebuildapplies the batch's own schedule, the single-batch result §7.3 defines, and the corpus parity tests still matchfossil rebuildrow for row. One departure from fossil, which follows the draft: a recheck also covers the parents a check-in has when the schedule runs, not only those it had when scheduled, so a tag applied before its target is linked, or a reparent inside the batch, gives the same table in any link order. The reparent case is split:repostate.4.2.reparentloads in one batch, and the newrepostate.7.3.reparent-no-recheckloads per artifact, where the old parent keeps its verdict, as in fossil's own crosslink. - A check-in that a sync linked through the phantom-fill cascade now gets its
leaf row and propagated tags (#414). A pulled check-in arrives as an
announced phantom filled the next round, so the cascade links it, and the
session's closing sweep then found nothing left to link and skipped the
leaf and tag-propagation repair: the check-in had an event row and no leaf
row, and its parent stayed a leaf. The cascade now reports what it linked,
and the sync client and the server handler each run the repair once at the
end of the session when the cascades or the sweep linked anything, as
repo-state-01 §7.3 (RS-7.3-4) requires before the leaf table is read; not
once per file. New cases
repostate.7.3.pulled-childandrepostate.7.6.pulled-retageach also run as a.pullcase, which pulls the script's last artifacts from a go-libfossil server into a repository already holding the first. A session that fails after a round whose cascade linked check-ins runs the same repair before returning its error, as fossil's per-round manifest_crosslink_end does. - A received delta stored before its basis is now checked against its HASH
when the basis arrives later in the same pass: the same reply on the sync
client, the same request on the server, the same round of a clone
(sync-protocol-01 Section 7.7, SP-7.7-4). It was kept unverified, and the
mismatch surfaced only as a logged warning from the after-dephantomize
cascade and an expansion error wherever the artifact was later read. Each
receiver now remembers the deltas its pass stored dangling, and the store
that makes one's bytes available checks it in that store's transaction,
including a delta whose chain to that basis runs through a row an earlier
pass stored. The check applies each delta of a chain to its parent's
bytes, so a clone's chain of n deltas costs n applies, not n squared. A
mismatch aborts the whole pass, as fossil's commit hook does (see the
#418 entry below): the client's sync and a clone fail, and the server
answers
wrong hash on received artifact: HASHnaming the delta. The casessync.7.7.late-basis-wrong-hash,sync.7.7.clone-late-basis-wrong-hashandsync.7.7.server-late-basis-wrong-hashnow pass. Tracked in #412. - Each server request, each client reply and each clone round now runs in
one write transaction, as fossil's
page_xferruns a request afterdb_begin_writeandclient_syncruns a round betweendb_begin_transactionanddb_end_transaction(sync-protocol-01 Sections 7.7 and 10.1). A received delta found wrong only when its basis arrives later in the pass fails Section 7.7's commit check, and that now rolls back everything the pass stored, the basis, the phantom-fill crosslink cascades and the server's closing leaf and tag repair included, where it used to undo only the delta and keep the rest. The server still answers with the wrong-hash error and its timestamp comment, assync.7.7.server-late-basis-wrong-hashrequires; fossil 2.28 and 2.29 differ, since their commit hook fails insidepage_xferand the request ends with an HTTP error page instead of that card. A client's sync and a clone fail with the error. The server'sinvalid clone sequence numberfatal rolls the request back too, so the side-effect-free walk of the request that stood in for that rollback is gone; a request that pushes a file with a wrong hash ahead of such a clone card now gets the file's error, where page_xfer's card loop breaks, not the clone's. Every other card error still keeps the work before it, as Section 10.1 requires: on the client that prefix now commits whole, where a card error used to return before the reply's buffered igot phantoms and visibility changes were written. The deviation onsync.7.7.late-basis-aborts-passandsync.7.7.server-late-basis-aborts-requestis gone, so SP-7.7-4 is covered, and the new requirement SP-10.1-6 has the casesync.10.1.integrity-error-prefix. fossil 2.28 stores a file whose hash is wrong and then aborts at commit, which rolls that prefix back; the case follows the draft. After such an error the client ends the session instead of evaluating continuation (Section 3.7); that is SP-10.1-7, a marked gap tracked in #421. Server requests, pulls and clones included, now hold the repository's write lock for the whole request, as fossil'sdb_begin_writedoes, so concurrent requests to one repository wait on each other up to the busy timeout. AContentCachemust not outlive a rolled-back pass: a rolled-back request frees row ids that later inserts reuse. Tracked in #418. - A received delta against a held basis whose bytes are not its name was
stored unchecked: the basis failed its own hash check, so the delta was
treated as having no basis. The basis is now reconstructed without that
check, as fossil's
content_getdoes, and the delta's result checked against its HASH (Section 7.7 hashes the reconstructed artifact, not its basis), so a mismatch is refused and a match kept. A held basis whose bytes cannot be rebuilt at all (bad zlib, or a delta in its chain that does not apply) is a mismatch too: fossil'scontent_getkeeps the bytes it has, so the delta is applied to those and the result refused. New cases:sync.7.7.misnamed-basis-wrong-hash,sync.7.7.misnamed-basis-verifiedandsync.7.7.unreadable-basis-wrong-hash. - The crosslink sweep no longer holds back a check-in for absent file
content (#406). A root check-in, or one on a present parent, whose files
never arrived got no event, plink, mlink, leaf or tag rows, so it never
appeared. The sweep --
Crosslink, the step that ends every sync and clone, andverify.Rebuild-- now links it against phantoms for the absent files, as fossil'sadd_one_mlinkdoes and as repo-state-01 §4.2 (RS-4.2-2) requires of an unresolved reference; the content fills the phantoms in place when it arrives. The receive-time paths, the phantom-fill cascade and a clone's receive linker, still wait for files while a session is delivering them, soCheckout.Updatedoes not meet a check-in whose files are in flight; the closing sweep bounds that wait. New case:repostate.4.2.present-parent-absent-file; the gap caserepostate.4.2.root-absent-filenow passes, and both also run throughverify.Rebuild. verify.Rebuildno longer links a delta manifest whose B-card baseline is absent (#406). It wrote event and plink rows, with no baseline id, wherefossil rebuildrefuses it (fetch_baseline). Every crosslink path now refuses it the same way, as artifact-format-01 §8.3's soft failure: the baseline gets a phantom, so a sync requests it, and the manifest is filed inorphanunder it, where the phantom-fill cascade links it when the baseline arrives. Before, a sweep deferred it without either, so nothing asked for the baseline. A baseline that is stored but is not a check-in manifest is refused the same way, asfetch_baseline'smanifest_getdoes. Theorphanrow is dropped whenever the manifest links, by any path.verify.Rebuildclears and rederivesorphan. New requirement RS-3.1-3 and casesrepostate.3.1.absent-baseline(with.rebuild),repostate.3.1.non-manifest-baseline(with.rebuild) andrepostate.3.1.late-baseline.- A check-in whose primary parent is absent is no longer held back for
absent file content (#398). The crosslink sweep, the receive linker and the
phantom-fill cascade deferred it until its files arrived, but with no parent
manifest there is no file transition to record, so the wait could never end
for a check-in whose files the repository will not receive. Its event and
plink rows and its parent phantom were missing where
fossil rebuildwrites them, as RS-4.2-2 requires: the libfossil repository holds one such fossil-scm check-in, imported without its history. Its file transitions are derived when the parent arrives, against phantoms for files still absent, as fossil's do. New case:repostate.4.2.phantom-parent-absent-file. A root check-in, or one on a present parent, with absent files is covered by the #406 entry above. - A received
fileorcfiledelta whose basis is held is now checked against its HASH before it is stored, on the sync client, the server and a clone alike (sync-protocol-01 Section 7.7, SP-7.7-4). The server had checked only afiledelta, since #402, and the client and clone stored every delta as given. A delta whose result is not HASH, or that does not apply, is not stored: the client's sync fails and a clone fails with the wrong-hash error a full file already gets, and the server answers with its wrong-hash error. A basis not held still leaves the delta stored dangling, as fossil does. fossil checks afiledelta on receipt and acfileor clone delta in its commit hook; this checks them all on receipt. Tracked in #405. A delta whose basis arrives later in the same reply or request is covered by the #412 entry above. Checkout.Updatewith no target, andCalcUpdateVersion, now pick the target as fossil'supdatedoes when no version is named: the newest open leaf descended from the checkout (every descendant leaf when all are closed), kept to the checkout's branch when there are several. If several remain on that branch the update fails ("multiple descendants") instead of guessing. It previously moved to the newest leaf on any branch, including leaves that do not descend from the checkout. repo-state-01 does not define update with no version named. Where fossil 2.28'scompute_leavesreads branch rows without tagtype > 0 and an untagged check-in astrunk, this reads active rows only and themain-branchsetting, as Sections 6.4 and 6.1 require of branch membership; an untagged checkout is kept to the default branch, where fossil keeps none of the leaves and stays put, and untagged leaves count as the default branch's. Section 10.3 says closed-ness and leaf-ness are not inputs to update's target computation; that is read as covering a named update, while fossil's no-version update drops closed leaves and works from the leaf set (an erratum candidate).- The initial check-in starts the branch named by the
main-branchsetting (branch tag andsym-tag), not the literaltrunk, as repo-state-01 Section 6.1 and fossil'scommitrequire. LeafAmbiguousis false for a check-in with no active branch tag: Section 6.1 attributes it to the default branch but says that does not make it an open leaf of that branch, and fossil'sleaf_ambiguitycompares raw values, where a missing row matches nothing. It used to compare against the default branch's open leaves.- A check-in now verifies its own R card before it is linked: the stored manifest is read back and the digest recomputed over the tree it resolves to (a delta manifest's baseline merged with its own cards) must equal the R card byte for byte, as fossil's checkin.c self-test does and artifact-format-01 6.3.4 requires. A mismatch, such as an uppercase R card, fails the commit. Tracked in #387.
- A
hash-policysetting that is not one of the ordinals 0 through 4 is now an error when content is named or stored, not a silent fall back to the default (artifact-format-01 7.1: an unrecognized policy value is fatal), andRepo.SetConfigrefuses to write such a value, as fossil'shash-policycommand does. fossil 2.28 reads a non-integer value as 0 (sha1), treats a negative stored value as unset (db.c) and leaves a value above 4 undefined; -01 is followed here, so every stored value outside 0 through 4 is an error. - The sync client treats a reply
gimmeorigotwhose HASH is not an artifact name as fossil's client does: it reportsunknown command: [LINE]and stops reading the reply, as sync-protocol-01 Section 5.2 (SP-5.2-8) requires (#397). It acted on the card and read on. The server already answers such agimmeandigotwith bad command; a new case pins thegimmehalf. The same holds for a reply
fileorcfilewhose HASH or DELTASRC is not an artifact name: the client reportsmalformed file line(cfile) and stops reading the reply, keeping the prefix already processed, as fossil'sxfer_accept_fileand client loop do (#397). It aborted the whole sync with an invalid-UUID error before.An
igotunder push reclassifies an artifact or phantom the server already holds, as sync-protocol-01 Section 7.5 (SP-7.5-5) and fossil'scontent_make_privateandcontent_make_publicinpage_xferdo (#391). A publicigot HASHmakes it public;igot HASH 1makes it private, from any pushing requester, though one without the private capability still gets no new phantom. Several igots for one name in a request apply in card order. This follows fossil: a requester who may push can make a private artifact public by announcing it, and a client that pushes its private igots makes the server's copy private again.A pushed
fileorcfilecard that fails the server's handler now resets the reply, sends the one error and ends card processing, as sync-protocol-01 Section 5.2 (SP-5.2-7) and fossil'sxfer_accept_filepath inpage_xferrequire (#390). Content that is not its HASH is answeredwrong hash on received artifact: HASH, a HASH or DELTASRC that is no artifact namemalformed file line(orcfile), and a storage failure its error; each was appended to the reply and the next card handled. Afiledelta whose basis the server holds is now applied and checked against HASH before it is stored, the pre-storage verification Section 7.5 gives a reconstructable non-clone file. Acfileis checked too, where fossil(1) 2.28 stores it unchecked and aborts at commit; the wrong-hash cases are not sent to fossil(1) for that reason.The server sends a clone's reply (the unversioned catalog under uv-sync, the snapshot cookie, the cfile batch,
clone_seqnoand the push card) at the clone card, and the cluster igots ofpragma req-clustersat the pragma, as fossil'spage_xferdoes. A later reset, from a bad command or a refused push, now discards them, so the reply is the error alone (sync-protocol-01 Section 10.1, #389). They were sent after the card loop and survived the reset. The clone snapshot cookie is read before the card loop, since fossil's client sends it after the clone card; apragma send-privatemust now precede the clone card to include private content, as in fossil.pragma ci-lockandpragma ci-unlockare applied for any requester, with no Write-capability test, as sync-protocol-01 Section 9.4 and fossil'spage_xferdo (#392). The lock table stays bounded without that gate: eachci-lockfirst deletes every lock whose check-in is not a leaf, so at most one lock per leaf and one other survive. nobody's lock row stores a null login, as fossil's does, andci-lock-failnames such a holdernobody, since -01's grammar requires a LOGIN token where fossil sends an empty one. The LOGIN inci-lock-failis fossilized on the server and defossilized on the client. Thelock-timeoutsetting is read as fossil'sdb_get_intreads it: a value of 0 or less, or one that is not a number (read as 0), makes every lock stale, where it previously fell back to 60 seconds.The sync client answers a server's
gimmefor content it holds as private, outside a private sync, withigot HASH 1when the server'spragma server-versiondates it 2020-04-13 or later, and with nothing for an older or undated server, as sync-protocol-01 Section 8.6 and fossil'ssend_filedo. Such an answer to the first reply continues the session once (Section 3.7 limb c). It previously sent nothing.The
uvfile,uvigotanduvgimmeNAME is fossilized on the wire, as sync-protocol-01 Section 7.3 and Appendix B require: a name with a space goes out asa\sb.txt, and a received NAME is defossilized before it is checked as a pathname and stored. fossil(1) 2.28 sends the name raw and checks the token as sent, refusing any escape; names that need no escaping are unchanged on the wire, and the code follows -01. A raw backslash escape in a received NAME is now undone, where it was refused. Theuv-hashcatalog hashes and sorts each name in its fossilized form (Section 8.5.1), which is what fossil(1) hashes for a name it holds as sent.The server's check-in locks share fossil's lock table: a lock is the config row
ci-lock-HASHholding JSONloginandclientid, timed by the row'smtime, where go-libfossil wrotelibfossil-ci-lock-HASH, so a repository served by both saw two tables. A live lock is now kept as it is, holder and time, when its own client asks again, where it was refreshed (Section 9.4), and a lock is stale once it is the lock timeout old, not only after. The timeout is the repository'slock-timeoutsetting, 60 seconds when unset, as in fossil. Rows left under the oldlibfossil-ci-lock-key are deleted the next time a lock is requested.pragma ci-lockis taken only with exactly a hash and a client id, as in fossil, where it wrote a lock for any two tokens. The server still ignoresci-lockandci-unlockfrom a requester without Write capability, where Section 9.4 applies them to any (#392).The sync client's unversioned-file session now stops after request 3 when that request's
uvgimmebrings back nouvfile, as Section 3.7(d) and fossil'snCycle<3do; it ran one request more.Looking up one file in a delta manifest follows artifact-format-01 Section 8.4: the delta's own F cards are searched first, and a delete marker answers not-found without reading the baseline, so a deleted name no longer fails when the baseline is missing. A name the delta does not mention still needs the baseline and fails without it. The new
manifest.LookupFileservesRepo.ReadFile,manifest.FileAt, annotate, the merge resolver and conflict-file reads, and no longer lists the whole file set for one name.deck.Deck.Marshalwrites the K card's ticket ID (Section 4.7.9) and the R card's checksum (Section 6.3.4) in lowercase, as those sections require of a writer, even when the deck came from an artifact whose parser-accepted card was uppercase.The sync server handles request cards in request order, as fossil's
page_xferdoes (#385). Before this, the login card was verified first and every control card was handled ahead of the data cards. So a file card sent before its push card was stored, and cards sent before a successful login got the login's capabilities. Now that file card is refused withnot authorized to write, and cards before the login get nobody's capabilities. fossil rolls back the whole request when it reaches an invalid clone sequence (sync-protocol-01 Section 8.1). Here a request that holds such a clone card is first walked without side effects, so no earlier card writes before the fatal.A push refused without an earlier pull now resets the reply and is answered
error not authorized to write, and card processing continues. A refused pull resets, is answerederror not authorized to readand ends card processing. These are the sync-protocol-01 Section 6.6 texts. They replacepush denied: insufficient capabilitiesandpull denied: insufficient capabilities, which neither reset nor stopped (#385).After an accepted push the server now asks for its own phantoms, as fossil's
request_phantoms(&xfer, 500)does. It sends at most 500 gimme cards, ahead of a pull's igot inventory, and also after a broken card loop (Section 6.4). Phantoms the client asked for this round, shunned names and private ones outside a private sync are left out. A pushed igot now makes a phantom, as Section 8.5 says, instead of drawing a gimme on the spot. So a file card later in the same request fills the phantom without being asked for. An igot or gimme whose name is not an artifact name is a bad command, as in fossil (#385). Unlike fossil's validate16, an uppercase hex name is refused too, since the blob table only holds lowercase names. A run of igot cards stores its phantoms in one transaction. Still open: #389, #390, #391.A clone no longer aborts on a reply card it cannot read (Sections 5.2 and 10.1). An unknown or malformed card ends the reply-card loop, and the cards before it are kept. The session continues, including the mandatory second clone round, and then fails naming the card, as the pull path already does after #373 (#385).
The leaf table reads a check-in's branch through its active branch row only (tagtype > 0), on both the crosslink sweep and the commit path, as repo-state-01 Section 6.4 requires of every branch-membership test. A check-in whose branch tag was cancelled with a value no longer stays a leaf on that value's account. fossil 2.28's
leaf_checkreads the row unfiltered, so this departs from fossil; itsis_a_leafandleaf_rebuildfilter.A check-in with no active branch tag is attributed to the
main-branchsetting,trunkonly when it is unset, as repo-state-01 Section 6.1 requires and fossil'sbranch_of_riddoes. This affectsroot:,start:andmerge-in:resolution,checkout.LeafAmbiguous, andcheckout.BranchLeaves(""), which now lists the default branch.repo.MainBranchreads the setting; an empty value counts as unset, which departs from the draft and from fossil'sdb_get. The leaf table andcheckout.WouldForkapply the same default to a check-in with no active branch row, where fossil'sleaf.cuses the literaltrunk.verify.Verifyandverify.Rebuildreport a blob whose bytes do not hash to its stored name asIssueHashMismatch, notIssueBlobCorrupt.content.Expandchecks the name itself and its error now wrapscontent.ErrHashMismatch, so the report can keep the two apart, as repo-state-01 Section 4.4 asks.deck.Parseaccepts every Julian-day spelling artifact-format-01 Section 4.6.4 form 5 allows: a sign, an exponent, a leading point and a trailing point. Hex,inf,nanand_stay rejected, and the bound is now checked after rounding to the millisecond, so5373484.4999999999and1e-10are rejected as in fossil (#370).manifest.ListFilesand crosslink accept a delta manifest whose B card names another delta manifest, as artifact-format-01 Section 4.7.3 requires and fossil's manifest_get does. The file set is the named delta's own F cards overlaid by this manifest's, one level, without following the named delta's B card. This reverses the refusal added for #275; -00 forbade it and -01 reverses that, so the code follows -01. The named delta's delete markers are dropped from the set, and the commit writer never emits a B card naming a delta manifest (#371).A check-in's event time now follows repo-state-01 §4.2 step 6 whatever order the check-in and its
datetags arrive in (#372). Crosslinking a check-in read itsdatetagxref row whatever its source, so re-crosslinking after a propagatingdatetag retimed every descendant that had inherited it; only a tag applied directly to the check-in now sets its time. A cancelleddatetag now restores the D-card time, and a value that does not parse, or parses (by SQLite julianday) to a time that is not positive, is ignored and keeps the D-card time, where go-libfossil, like fossil 2.28, stored no time or a time of 0. The rule lives in one place,tag.RefreshEventTime, which both tag application and crosslinking call;event.omtimestill keeps the D-card time. This departs from fossil 2.28, whose result for these cases depends on arrival order. Thedate-tag-cancel,-unparsableand-zeroconformance cases are no longer marked as deviations, and new-before-checkincases check the same three with the tags arriving while the check-in is a phantom.The sync client no longer sends an unversioned file named by SHA3 to a server whose
pragma server-versionpredates SHA3 support (#303). It sendserror Fossil\sversion\s2.0\sor\slater\srequired.in its place, as sync-protocol-01 Section 7.6 and fossil'ssend_unversioned_filedo, whether the card would carry content or not; a deletion tombstone names no hash and still goes. Artifacts were already gated this way.Outside a private sync, the sync server answers a gimme for private content with
igot HASH 1when the peer'spragma client-versiondates it 2020-04-13 or later, and with nothing for an earlier or missing date, as sync-protocol-01 Section 8.6 and fossil'ssend_filedo (#375). It used to send nothing whatever the date. The server now records the peer's check-in date from the pragma.The sync server's refusals now follow sync-protocol-01 Sections 5.3, 6.4 and 6.6 (#374). A file or cfile card sent without an accepted push resets the reply, is refused with
error not\sauthorized\sto\swriteand ends card processing, where it used to be refused alone (file HASH rejected: no push card) and the cards after it still ran. A refused login (a wrong password, or a second login card) ends card processing at its own position instead of ending the request: the cards before it keep their effect, the igot inventory of an accepted pull still follows the error, and the reply ends with the timestamp comment.errors Nin that comment now counts the errors raised, including those a later reset discarded, and leaves out the bad-command error, the private-content refusal and the SHA3 refusal, as fossil'snErrdoes.A file, cfile or uvfile payload cut short by the end of the body is now the card's handler failure, as sync-protocol-01 Section 5.1 requires (#373). The decoder used to fail the whole message, so a server answered HTTP 400 and a client dropped the reply. It now yields the card as a malformed line and stops: a server answers 200 with
error malformed\sfile\sline(or the cfile or uvfile text) and keeps the effect of the cards before it, and a client keeps the prefix it has processed and records the error. The short payload is never stored. This follows -01, not fossil 2.28, which stores the bytes present.Tests that run
fossil serverno longer flake with "directory not empty" during temp-dir cleanup (#352). Fossil forks a child per connection, and killing only the parent let a child write into the directory while it was being removed. They now sharetestutil.StartServer, which puts the server in its own process group and kills the whole group before reaping it.A check-in's
event.bgcolor,euserandecommentnow follow itsbgcolor,userandcommenttags as fossil'stag_insertandmanifest_crosslinkdo (#343).tag.ApplyTagWithTxsetevent.bgcoloronly for a propagatingbgcolortag, so a singleton one did nothing and a cancel left the colour in place, and it never touchedeuserorecomment. It now sets all three for every tag type, writing NULL for a cancel, and crosslinking a check-in reads the three values back fromtagxref, so a tag that reached the check-in while it was a phantom takes effect.The sync server's
xtable-hashnow needs a direction card (#351), not Read alone. Read is derived for a Clone-only requester (#312), which then received an inventory of table keys and mtimes it can neither pull (xgimme needs a pull card) nor push against. The gate is Read plus an accepted pull or push card: a push-only table-sync client still gets the inventory it uses to queue newer local rows. Table sync is a go-libfossil extension with no fossil counterpart. The private-clone card order the issue also raised needs no change: the clone client has no private mode and never sentpragma send-private; a comment records fossil's order for when one is added.The sync client's per-request
gimmelimit now follows fossil'smxPhantomReqexactly (#350). The cap itself (200, or twice the files the last round received) was already in place, but it was recomputed every round; fossil assigns it only when a round continues for its phantoms and otherwise keeps the earlier value, so a round that continues to send files no longer drops a raised limit back to 200. Tests pin the 200 start and the growth rule.The sync server no longer asks for a shunned artifact (#349).
handleIGotanswered anigotfor a shunned hash withgimme; fossil'scontent_newreturns 0 for a shunned name, so it makes no phantom and requests nothing. Private igot emission is deliberately unchanged: fossil'ssend_privatehas no shun filter, so a shunned private artifact is still announced (contrary to what #349 proposed); a conformance case pins this.Tests that run the fossil binary no longer write into the developer's global fossil config (
~/.fossilor~/.config/fossil.db) (#284). Eachfossil new,open,cloneandserveon a temporary repository left arepo:orckout:row there that was never removed, and parallel package runs contended for that file's lock.testutil.RequireFossilBinnow pointsHOME,FOSSIL_HOMEandXDG_CONFIG_HOMEat a per-test directory through the newtestutil.IsolateFossilHome, and the tests that resolved fossil withexec.LookPathuse it.TestFossilInvocationsIsolateHomefails when a Go file runsexec.Commandwithout one of these.Checkout.WouldForkagain reports a fork when the checkout's version already has a child on its own branch (#347), as after a pull brings in a child of the checked-out version. Since #291 it was fossil's leaf-ambiguity test, which answers no for a version that is not a leaf, so a commit there created a fork. It is now fossil's commit-time "Would fork" check (!is_a_leaf); another open leaf elsewhere on the branch no longer counts, since a commit extends the checkout's own leaf. The repo-state §7.5 ambiguity test ischeckout.LeafAmbiguous.branch.Createrefuses an open branch name inside the check-in's own transaction (#316). The check ran beforemanifest.Checkinopened its transaction, and the branch tags were applied in further transactions after it committed, so concurrent creates of one name could all pass the check and all commit.manifest.Checkinnow applies its inline tags, parent tag propagation and leaf state inside its transaction, and takes aPrecheckthat runs first in it;branch.Createchecks the name there, so exactly one of several concurrent creates commits.verify.Rebuildis atomic again (#335). Since #285 it committed the emptied derived tables before the crosslink sweep ran, so a sweep that failed partway left event, plink, mlink, tagxref, leaf and the rest empty until a later rebuild succeeded. It now empties, re-derives and refills the bookkeeping tables in one transaction, as fossil'srebuild_dbdoes, and a failed rebuild rolls back to the state it found.manifest.CrosslinkRebuildnow takes the caller's transaction and runs candidate discovery, linking and the leaf and tag repair inside it; the incrementalCrosslinkpath is unchanged.A check-in's
event.mtimenow follows its newestdatetag, the one-shot tagfossil amend --datewrites, as fossil'smanifest_crosslinkandtag_insertdo (#334). Crosslink used the manifest's D-card time and never applied the override, so every date-amended check-in sorted at its original time in the timeline,tipand branch listings. Crosslinking a check-in now reads a date tag already recorded for it, and applying a date tag later moves the check-in'sevent.mtime, keeping the original inomtime.tag.AddTagnow derives its tag through the same path crosslink uses, so it movesevent.mtimeandevent.bgcolortoo.verify.Rebuildnow derives repository state through the crosslink sweep rather than its own partial derivation (#285). It kept a second derivation that wrote rows only for check-ins and control artifacts: no cluster, wiki, ticket, technote or forum events, applied tags from artifacts the crosslink would refuse, skipped absent tag targets and parents instead of reserving phantoms, wrote no plinkbaseid, and computed leaves by "no child at all" regardless of branch. Rebuild now empties the derived tables and runs the same sweep a clone runs, so derived state has one owner and matchesfossil rebuildin event, plink, mlink, tagxref, leaf and phantom. A newmanifest.CrosslinkRebuildruns that sweep in a terminal mode that links a check-in whose content is absent with phantoms rather than deferring it for a blob that will never arrive, asfossil rebuilddoes. Its branch-aware leaf rule now follows fossil, so an untagged parentless check-in is no longer a leaf (the §7.3 erratum the sweep already observed).The sync server lets Clone imply Read at its read gates, as fossil's trunk
page_xferand the §7.3 erratum do (#312). This is an access change: a requester whose anonymous (nobody) capabilities include Clone (g) now receives unversioned files (the catalog,uvgimmeanswers, and the catalog sent with a clone), thextable-hashinventory and read-gated configuration fromreqconfig. As in fossil, a Clone granted by a login card does not imply Read. Pull still needs Read itself (o): fossil trunk's derived Read also lets a Clone-only nobody pull, and this server deliberately does not. One handler check,canRead, now decides every read gate;auth.CanReadis unchanged.Sync login follows §6.4 and §6.5 (#304). Once a server's
server-versionnames a release that reads it (2.27, built 2025-07-27 11:05 or later), the client's HTTP transports send the login card in thex-f-l-ccookie, urlencoded, instead of the body, as fossil's client does; other transports keep it in the body. The server, when a user's stored password is not a 40-character shared secret, checks the signature again against the secret that password derives, as fossil'scheck_logindoes for a password stored in clear. A newHandleOpts.LoginCookiefield carries the cookie toHandleSyncWithOptsfor an embedder that serves sync over its own HTTP handler; without it, a client's cookie login reaches such a server as nobody.XferHandlerandServeHTTPread the cookie themselves.Unversioned sync applies a received
uvfileas fossil'sxfer_accept_unversioned_filedoes, on both sides (§7.3, #308). The row keeps the HASH the card gave rather than one the repository's hash policy computes, so both catalogs agree; the same content at a later mtime (status 2) moves only the mtime, where it was dropped; and a card that omits its content stores the row with NULL content and size 0, where it changed nothing. The server answers auvgimmefor a name it does not hold with no card, where it sent a tombstone. The client no longer seeds its send table with tombstones (§8.5.1). A received deletion of a name the repository does not hold changes nothing, as fossil's update does. Auvfileline whose HASH is neither an artifact name nor-, or whose NAME is not a strict simple pathname, is malformed and refused, where its HASH was stored as given; and only a card that carries content has its HASH checked, so a deletion or a row stored without content relays.Clone replies follow fossil's
page_xfer(#306). Eachcfilecarries the artifact as the blob table stores it, verbatim, followed by an LF when its payload does not end in one (§5.1, §7.2), where the server recompressed it and never padded. Over HTTP a version 3 clone's reply isapplication/x-fossil-uncompressed(§4.1). With theuv-syncsetting on, a clone reply carries the unversioned catalog underpragma uv-pull-onlyfor a requester with Read (§8.4). A clone refused for want of Clone resets the reply and sends the repository'spushcard ahead oferror not authorized to clone(§8.2, §10.1), where the error was appended to what the reply held. A clone whose SEQNO is negative gets no content andclone_seqno 0, where it restarted at the first artifact.The sync server answers three more cards as fossil does (#305). A pushed
configcard from a requester without Admin is refused witherror not authorized to push configuration, reset and break (§9.3); one from an Admin is refused as unsupported, since this server does not apply pushed configuration, where both were silently ignored.pragma ci-unlockreleases the client's check-in locks (§9.4), for writers, asci-lockis taken.pragma req-clustersis answered with the clusterigotcards between# sending-clustersand# end-of-clusters(§5.3); it was recorded and never answered. This is an access change: as in fossil, any requester, one without Read included, is sent the names of the public cluster artifacts. Their content still needs Read to fetch.Sync exchanges releases and withholds SHA3 content from older peers, as fossil does (§7.6, §9.1, #303). The server answers a
pragma client-versionthat carries DATE and TIME with its ownpragma server-version, ahead of any content. Both sides now record the peer's release, and where they would send content named by SHA3 to a peer older than fossil 2.0 (a client that announces no release counts as one), they senderror Fossil version 2.0 or later required.in its place: the server forgimme, clone anduvgimme, the client forgimme. Because a server-version from a current release has fossil's client log in through thex-f-l-ccookie, the HTTP server now reads that cookie's login card, its nonce covering the whole body (§6.5).The sync client ends a session by §3.7's rules, as fossil's
client_syncdoes (#301). Stored content or a new phantom continues it only while the phantom table is not empty, so neither received content alone nor phantoms the server cannot supply keep it going; a phantom used to be asked for until it had aged three rounds. Agimmefor content the client does not hold, or may not send, is dropped where it stayed queued and held the session open untilMaxRounds. Anigotfor a missing artifact now records a phantom in the phantom table, so a later session still asks for it. After a clone's first round, a servererrorends that reply and the clone continues as the draft says, with a cleanup round, andClonethen fails naming the error. Phantoms a pull fills are crosslinked once per reply instead of in one transaction each.The sync client places
pragma send-privatewhere fossil'sclient_syncdoes (§3.2, #307): first in its first request and right afterclient-versionin later ones, where it trailed the content cards. A delta's missing basis received under aprivatecard becomes a private phantom, as fossil'scontent_newmakes it, where it became public.Sync honors the shun list as fossil does (§3.3, §7.7, #302). A received
fileorcfilewhose hash is shunned is dropped without an error and not counted as received, by the server and the sync client, where each stored it; a clone drops a shunnedcfilebut keeps a shunnedfile, as fossil's!cloneFlagexemption does. The client no longer asks for a shunned phantom: the phantom scan, anigotand a clone's delta basis all leave shunned names out, so a delta whose basis is shunned is kept and the basis is never requested.shun.IsShunnednow reports a failed lookup as an error instead of "not shunned".The sync server stores a
uvfilesent without apushcard, asfossil uv syncsends it (§7.3, #296). It answered each one withrejected: no push card, so pushing unversioned files from fossil failed. This is an access change: a user with only the unversioned-write capabilityycan now push unversioned files, as in fossil; Write is no longer also required. A refuseduvfile(noy, or a HASH that does not match its content) now resets the reply and ends the card loop, as fossil does.The sync server refuses a
pushorpullcard naming another project witherror wrong project, as fossil'spage_xferdoes (§8.5, #309). It accepted either whatever its project code, so a client of an unrelated repository could pull its artifacts or push into it. The refusal comes before any capability check, resets the reply and ends card processing.Sync HTTP framing now follows §4 (#310). The server dispatches a request typed
application/x-fossil-debug, which it refused with HTTP 415. The client takes only HTTP 200 as a sync reply, so a 201 or other 2xx is an error, and reads a reply's framing as fossil'shttp.cdoes: no Content-Type is the compressed container, anx-fossil-debugorx-fossil-uncompressedprefix is plain cards, any otherx-fossilprefix is compressed, and a foreign type such as a proxy's HTML page is refused rather than read as cards. A server that writesHandleSync's reply over HTTP must type itapplication/x-fossil.Unknown, wrong-direction and error cards now follow §5.2 and §10 on both sides of a sync (#300). The server answers a client-sent
errorormessagecard — reply-only cards fossil'spage_xferhas no branch for — withbad command, resetting the reply, rather than ignoring it. The client ends its reply-card loop on anerrorcard, keeping the cards before it, as fossil'sclient_syncdoes. Amessage pull only ...from the server (§10.2) now stops the client pushing: it keeps pulling but answers no further gimme and drops its push card one request later, matching fossil's clearing ofSYNC_PUSH. To make that reachable, the client now sends itspullcard before itspushcard (fossil's order), and the server answers a push from a requester who may read but not write withmessage pull only - not authorized to pushinstead of an error, so the pull still proceeds.The xfer card decoder now follows the draft's card grammar instead of failing a whole message on one bad card (#299). A line no card rule accepts (an unknown operator, a bare
pragma, agimmewith a surplus token, aconfigwith a signed size) decodes to a bad-command card: the server resets its reply, answerserror bad command: <LINE>and parses on, and the client ends the reply-card loop keeping the cards before it. Afile,cfileoruvfileline that cannot be framed decodes to a bad-file card: the receiver emits the handler's message (malformed file lineand the like) and stops, as fossil'spage_xferbreaks. Tokens are now split on ASCII whitespace only, within fossil's six-token window, so a no-break space no longer separates tokens and a seventh token is ignored rather than rejected;igotandprivatetolerate a surplus token. A fullcfile's USIZE is treated as metadata, not checked against the inflated size, and the byte after aconfigpayload is consumed whatever it is, both matching fossil.Repo.ResolveVersionnow follows fossil'ssymbolic_name_to_ridname resolution (src/name.c): it chooses a tag by the carrying check-in'sevent.mtimerather thantagxref.mtime, no longer falls back to the tip when no check-in carriessym-trunk, tries an artifact-hash prefix before a plain tag, and accepts therid:,tag:,root:,start:,merge-in:andNAME:TIMESTAMPforms plus a trailing-punctuation retry. A hex prefix now must be 4–64 characters, as fossil requires (#290).branch.Createnow refuses a name an open branch already carries, asfossil branch newdoes, and stores no artifact when it refuses.branch.Listnow reports a branch closed only when its newest check-in carries aclosedtag (fossil's branch-list rule), not when any check-in on the branch does (#292).checkout.BranchLeavesnow returns only the open leaves of a branch, skipping any leaf that carries aclosedtag, as fossil'sleaveslists them (#291).A repository with no
hash-policyrow names new content as fossil'shname_default_policydoes (#274): SHA3 when it is empty or holds a SHA3 artifact, SHA1 (policyauto) otherwise. It used to name an empty repository's first artifact with SHA1. And when anautorepository takes in its first SHA3-named artifact, the promotion is written to thehash-policysetting, as fossil'scontent_putdoes, so fossil opening the repository agrees. It used to be derived from the stored names only.ListFilesand crosslink reject a B card whose target is not a baseline manifest (#275): a delta manifest or a control artifact used to be accepted as the baseline, giving a partial or empty file set instead of an error.Checkinrefuses a filename the artifact format's writer profile forbids (#270): one containing a backslash, or that is not valid UTF-8 (surrogates and overlong forms included). It used to write a manifest naming it. fossil's ownaddrefuses such names, so fossil never creates that manifest, but its reader still accepts one that arrives from elsewhere. The draft's writer profile also bars characters above U+FFFF, private-use characters and noncharacters; those are not enforced here.delta.Createrefuses a source or target of 2^32 bytes or more withdelta.ErrTooLarge(#279), as the delta format's producer bound requires; it used to write header and copy integers of 2^32 or more.Createnow returns an error, andDeltifystores such content whole, as fossil does when a delta is not worthwhile.Applynow rejects a header naming exactly 2^32 as well (the draft leaves that limit implementation-defined), so whatCreatewrites andApplyreads agree.Storing content whose name is a phantom fills the phantom in place, as fossil's
content_putdoes (#278).blob.Storeused to return the phantom's rid and leave it empty, so a check-in of a file matching a known phantom recorded a manifest pointing at content that was never stored.Repo.Tagnow places a tag name in the symbolic namespace with asym-prefix and refuses the reservedwiki-,tkt-andevent-prefixes, asfossil tag adddoes for a check-in. It used to store the name verbatim, so the tag did not resolve as a branch/tag name. Callers pass the bare name.Cancelling a propagated tag no longer deletes an inherited row that is newer than the cancellation. The cancel walk carried no mtime guard (
COALESCE(srcid=0, 0)); it now matches fossil'stag_propagate(srcid=0 AND tagxref.mtime < ?), removing an inherited row only when it is older and halting the descent at a newer one (#288).The singleton
parent(reparent) tag is now applied: the tagged check-in's plink and mlink rows are rewritten to the parents the tag names, and the affected leaves are recomputed, as fossil'smanifest_crosslink_enddoes. An old parent left without a child becomes a leaf, matchingfossil rebuild. Both the crosslink sweep andverify.Rebuildapply it (#286).A technote whose T-card names another artifact, or propagates or cancels, is now refused at crosslink time and applies none of its tags, as fossil refuses it at parse time. A technote may only carry
+(singleton) tags on itself (#287).Crosslinking a root check-in a second time no longer duplicates its mlink rows. The root-file path inserted each row with no "already derived" guard, so a re-crosslink (as the relink path does after clearing event/tagxref) doubled them; it now skips the block when the rows are already present, as
manifest_crosslinkis idempotent (#294).The first check-in's
sym-trunktag is now propagating (*sym-trunk), matchingfossil newand repo-state §6.1, so the trunk alias descends to every check-in on the branch. It used to be written as a singleton (+sym-trunk), which stopped at the root (#289).deck.Parseapplies the §5 structural-type rules once a type is determined: the §5.2 card-legality table (each type's allowed and required cards, from fossil'smanifestCardTypes), so a card the type disallows or a required card left out is rejected, and the §5.4 semantic checks -- a control artifact's tags may not target itself, a technote's tags are all self-referential singletons, a forum post carries at most one of an H or I card and at most one parent. Type determination now falls back to a control artifact (not an empty check-in) when no card sets a type and there is no comment, file or parent, matching fossil. This also makes a missing Z card a required-card rejection rather than a special case (#273).deck.Marshalre-encodes whatdeck.Parseread, soMarshal(Parse(x)) == xfor a canonical artifact. A present-but-empty C comment, N mimetype or T value, and a bare P card, are kept distinct from an absent one and re-emitted (an empty W body already was, now via nil versus a non-nil empty slice); the distinction is carried in unexported Deck/TagCard fields, so a caller that builds a deck and sets a non-empty value still has it marshalled and the public API is unchanged. The D and E timestamps are written at their writer precision -- millisecond for D, second for E -- extended to keep any finer fraction the parser read, so a sub-second timestamp round-trips instead of being truncated (#272).deck.Parseparses D and E timestamps with the §4.6.4 grammar (as amended by the §4.6.4 erratum) instead of two fixedtime.Parselayouts. It accepts the date-time form withHH:MM,HH:MM:SSor a fractional second, aZ,z,+HH:MMor-HH:MMtimezone, one or more whitespace-or-Tseparators, the hour 24 and an unchecked day (2024-02-31rolls forward, as SQLite'sjulianday()does), and a date at or after-4713-11-25; it applies the exact-digit field ranges (year 4 digits, month 01-12, day 01-31, hour 00-24, minute and second 00-59) and rejects a lowercasetseparator rather than silently rewriting it toT(#271).deck.Parseapplies fossil's §4.6 name predicates. An F-card filename and old-name, and an A-card filename, must be simple pathnames (§4.6.1): no leading or doubled or trailing slash and no "." or ".." path element, the non-strict form fossil uses, which still admits a backslash or non-UTF-8 byte. An L-card title and an A-card target (when not a hash) must be well-formed wiki names (§4.6.2): 1 to 100 bytes, no leading, trailing or doubled space and no control byte. A reserved repository filename (_FOSSIL_,.fslckout, in either case, with an optional -wal/-shm/-journal suffix, at the root or as a path's last component) is silently dropped from an F-card list after the sort-order check, exactly as fossil does (#269).deck.Parsevalidates every artifact-hash position against the §4.7.1 map: the A source, B, the E identifier, each F hash, G, I, M, every P token, both Q tokens, and the T target must be 40 or 64 case-insensitive hexadecimal characters; the K ticket id is pinned to 40; the R checksum to 32. A hash is required on every F card of a baseline manifest (only a delta manifest may carry a hashless deletion card). The A target and the N mimetype are now escape-decoded on parse and re-encoded on marshal, matching §4.3.1, while the other hash tokens stay raw; the A filename keeps only its final path component. The Q sign must be+or-, a tag name that is entirely hexadecimal (either case) is rejected, and these checks reject malformed tokens fossil also rejects, such as a hash carrying a stray carriage return (#268).deck.Parsetokenizes artifact cards the way fossil does (itsnext_token/next_card): tokens are separated by a single space, a card ends at LF, and tab and CR are ordinary token bytes rather than whitespace to split or trim on. A blank line, an empty token between two others (two spaces in a row), a bare card where a value is required, and trailing residue after the last card are each rejected; a line that ends in a space still carries a present-but-empty last value. The W card's size is read as fossil reads it -- leading decimal digits, with an overflow guard -- and the body must be followed by a newline. The clearsign armor trailer is matched on 29 bytes, as fossil matches it, so a mangled final dash no longer hides the signature. Each card is also held to its exact §4.2.1 arity: a card with more tokens than Table 2 allows is rejected (#266, #267).deck.Parseanddeck.VerifyZjudge an artifact's framing by the rule it breaks. The Z-card check now mirrors fossil'sverify_z_card: it locates the Z card as the last 35 bytes beginningZ, never reads the final byte, treats a body too short or without a trailing Z card as "Z card absent", and reports only a present-but-wrong checksum.Parsegained the §4.1 framing gates fossil applies up front -- a non-empty blob ending in LF, checked on the raw bytes before the signature wrapper is stripped, and a first card that is an upper-case letter followed by a space -- and a missing Z card is now rejected by the required-card rule rather than by the checksum. Artifacts rejected for a missing final LF, residue after the Z card, or a bad first line are no longer blamed on the checksum (#265).
[0.11.1] - 2026-10-11
Security
- The sync server sends repository content only to a requester allowed to
read it (GHSA-mvx3-6793-8x4r). It used to answer
gimme HASHwith the artifact whether or not a pull was accepted, andpragma uv-hash,uvgimmeanduvigotwith the unversioned catalog and files, to any requester, anonymous ones included. As in fossil'spage_xfer,gimmeis now answered only in a session whosepullcard was accepted, or in a clone, and the unversioned catalog and files go only to a requester with Read (o, i, or Admin).uv-push-okis sent only to a requester who may write unversioned files (y); a reader without it getsuv-pull-only, as fossil sends. The same rules apply to table sync:xgimmeneeds an accepted pull andpragma xtable-hashneeds Read. Auvigotno longer updates an unversioned file's mtime for a requester without y, andpragma ci-locktakes or reports a check-in lock only for a requester with Write (i), so an anonymous requester can no longer learn who holds a lock. This is a deliberate departure from fossil, which takes a lock for any requester; fossil's own client asks for one only before a commit, which needs Write. - The HTTP sync transports follow redirects as fossil does rather than as
Go's
net/httpdoes (GHSA-3f8x-8fr5-mr98). They used to follow a redirect from HTTPS to plain HTTP, which resent the request, login card included, in cleartext. They now refuse it, and follow a 301, 302, 307 or 308 by re-POSTing the same body and Content-Type, with one trailing/xferstripped from the new location, failing on the twentieth redirect. A 301 or 302 used to become a GET without the body, and a chain stopped after 10. This applies toNewHTTPTransport, whateverhttp.Clientit is given.
[0.11.0] - 2026-10-10
Added
Checkout.Mergemerges another checkin into the checkout, as fossil'smergedoes, andCheckout.Conflictslists the files left in conflict (#244). The merge is recorded the way fossil records one, so fossil reads it and commits it, and the nextCheckinrecords the merged-in checkin as a merge parent. A file edited here but deleted in the merged-in version is kept rather than deleted, and a merge that would overwrite an untracked file is refused: go-libfossil has no undo copy to recover them from. A change the merged-in version made to a file's executable bit is taken. Renames are not followed yet.CheckoutCommitOpts.Filescommits only the named files, andCheckoutCommitOpts.AllowConflictcommits files that still hold conflict markers (#244).
Changed
- Merge conflicts use fossil's conflict markers, with the common ancestor
shown between the two sides, so fossil sees a conflict go-libfossil wrote,
and the other way round (#244). This applies to
Updateas well asMerge. A file is in conflict while it holds markers; there is no separate conflict state to clear. - The CLI's
mergecommand callsCheckout.Merge(#244). It merges into the checked-out version; it used to merge against the repository's newest checkin, which made merging a branch that is the newest checkin a no-op. It no longer writes.LOCAL,.BASELINEand.MERGEfiles beside a conflicted file, since the markers now include the common ancestor.mark-resolvedrefuses a file that still holds markers, andconflictslists the files that hold them. - Breaking (CLI):
repo cicommits the checkout, asfossil commitdoes (#244). It used to commit the named files straight to the repository on top of its newest checkin, ignoring the checkout, so it could not record a merge and left the checkout behind. Run it in a checkout (or pass-d); with no files named it commits every change. The--parentflag is gone, since a checkout always commits onto its own version.--allow-conflictcommits files that still hold conflict markers, and--allow-emptycommits when nothing has changed. Checkout.Checkinrefuses files that still hold merge conflict markers, as fossil's commit does, unlessAllowConflictis set (#244).- Breaking:
Checkout.Checkinrefuses a commit with nothing changed and no merge pending ("nothing has changed"), as fossil's commit does, unlessCheckoutCommitOpts.AllowEmptyis set (#244). It used to record a checkin identical to its parent. A named file the checkout does not track is refused too; a named directory selects the tracked files under it. stash.Saverefuses while a merge is pending (#244). A cleanly merged file looks like a plain edit; stashing it left the merge record naming content that was no longer in the checkout.
Fixed
A pull-only
Syncconverges when the client holds artifacts the server lacks (#277). The server used to answer the client'sigotcards withgimmefor them even though the client was not pushing, so the client never satisfied the request and the pull ran toexceeded 100 rounds. The server now asks only a pushing client, as fossil does, and a client that is not pushing ignores anygimmeit receives.Repo.Annotatecredits each line to the right check-in when edits shift line positions between versions (#253). Past the first parent it used to write each line's attribution by its position in an older version, so lines were credited to the wrong check-in, and an older version with more lines than the starting one panicked with an index out of range.Check-in, parent-link and tag rows store the values fossil stores (#258), whether written by a commit, a crosslink or
verify.Rebuild. Times match bit for bit: tag times are SQLite'sjulianday(), and event, plink, attachment and forumpost times are that value as fossil writes it into SQL text, which keeps 16 digits; they used to differ in the last digits. Fossil's own text conversion depends on the platform it was built for, and this follows the macOS and Windows builds. A check-in'somtimeis recorded (it was NULL), and a tag without a value is stored as NULL rather than an empty string. The CLI'srepo tag lsreads such tags, which it used to misprint.Clone and sync exchanges follow draft-fossil-sync-protocol-00 (#255). A clone reply sends an artifact stored as a delta as a
cfilenaming its source, as fossil does in a version-3 clone; it used to send afilecard. A clone session stops asking once the server sendsclone_seqno 0, sending at most one cleanup request with no operation; it used to switch topullto fetch missing artifacts, which the draft forbids.Clonenow fetches any artifact still missing with a separate pull session afterwards, and still fails if one cannot be had: unlike the draft, which ends a clone with phantoms left,Clonereturns only a complete repository. A clone the server stops beforeclone_seqno 0fails rather than returning part of a repository. A sync now asks once, in its first round, for the phantoms a repository already holds, as fossil's does, without letting one the server lacks hold it open. Every request ends with a random comment, not only those with a login, and every completed reply ends with fossil's timestamp comment.Repo.Annotatecredits lines the wayfossil annotatedoes (#256, #257). It now uses a port of fossil's own line diff and, like fossil, diffs each older version against the starting one. Where a line repeats (blank lines, braces), which copy counts as new depends on the diff's tie-breaking, and go-libfossil's diff used to pick differently, crediting such lines to other check-ins. Trailing blank lines are no longer dropped from the result. On the RFC, althttpd, pikchr and wapp repositories every annotated file now matchesfossil annotateline for line. A line's text no longer includes a trailing carriage return, a leading byte-order mark is ignored, and a file fossil will not diff (one with a NUL byte, or a line over 32,767 bytes) returns an error. Annotate no longer allocates a table the size of both versions at every step.Checkout.Checkinrecords pending merges as merge parents, including merges made by the fossil binary (#244). It used to drop them, so a merge committed through go-libfossil looked like an ordinary commit. Committing only some files of a merge is refused, as fossil refuses it.Checkout.Checkinof a checkout with no version and nothing added returns an error instead of panicking (#244).The CLI's
mergerecorded a conflict aschnged=5, which fossil reads as "added by integrate", and recorded no merge parent (#244).
Security
A sync card whose size is not a plain decimal is a decoding error. A negative size, as in
file HASH -1, used to panic the client or server reading the message, and a signed size such as+5was read as a size. Sizes follow draft-fossil-sync-protocol-00'sdecimalgrammar and fossil's 32-bit limit, and auvfile's FLAGS take the same rule, so negative FLAGS are rejected as the draft requires. A declared size no longer sets how much memory is allocated before the payload arrives: the buffer grows with the bytes read, so a large size over a short message fails without allocating it. Acfilepayload may inflate only to its USIZE, or to a margin above USIZE for a delta, and not past its 4-byte size prefix, and allcfilepayloads in one message may inflate to at mostMaxCFileInflateBytes(1 GiB) together; a few megabytes of zlib could otherwise inflate to tens of gigabytes. A single artifact larger than that cannot be received as acfile(GHSA-j99p-mw22-69c8).An
xrowpayload orxdeletePKData must be one JSON object whose values are scalars and whose primary-key values have the column's type; anything else is refused with an error, on the client and the server. JSONnullused to panic the process, as did a primary-key value of the wrong type or a nested value. A client refuses anxrowfor a table it has not registered (GHSA-j99p-mw22-69c8).A synced table's row can no longer rewrite the SQL that stores it. Keys of an
xrowpayload orxdeletePKData were written intoUpsertXRow'sINSERTas column names unchecked, so a peer could choose the statement's columns and values, or select from other tables into a synced one. A peer's row must now use exactly the table's declared columns, andUpsertXRowrefuses any key the table's registered schema does not declare. Every other function that writes a synced table or column name into SQL validates it first (GHSA-9fq6-h6xw-274g).The HTTP transports read at most
xfer.MaxBodyBytes(65 MiB) of a reply and returnxfer.ErrBodyTooLargepast it; a server streaming an endless reply used to be read into memory whole (GHSA-j99p-mw22-69c8).The sync server answered
reqconfig NAMEwith any row of theconfigtable, to any requester, including one with no capabilities, so it disclosedproject-code,server-codeand every local setting. It now answers as fossil does: only a/GROUPrequest, only for a requester with Read, and only with the names fossil transfers in that group. The user and subscriber tables also need Admin, which counts only for a verified login, and concealed addresses need RdAddr (e). A refused request gets no cards and no error, so the rest of the exchange goes on. Groups are sent in fossil's encoding, sofossil cloneandfossil config pullagainst this server now receive the skin, project, ticket, shun and alias settings (GHSA-p6w4-4xqc-jhq2).The sync server checks a login card's nonce against the request body, as fossil does (GHSA-2g5p-q5f3-477p). The nonce must be the SHA-1 of the decompressed bytes after the login line; it was never checked, so a login card captured from one request authenticated any other request body. A refused login now ends the exchange with fossil's
error login failedinstead of carrying on as nobody, and a second login card is refused. A successful login adds the user's capabilities to nobody's instead of replacing them. As in fossil,nobodyandanonymouslog in without a password, and accounts with no stored password,developerandreadercannot be logged into.Breaking: A new repository gives its built-in users the capabilities
fossil newgives them.nobody, whose capabilities every request without credentials gets, may now clone, read and download (gjorz); it used to be grantedcghijknorswy, which let anyone push check-ins and unversioned files, write wiki pages and tickets, and act with Setup. The repository also gets fossil'sanonymous,developerandreaderusers. The creating user, who holds Setup, can now push and pull once given a password, as Setup includes those rights in fossil. A login is refused for any account with no password stored, as fossil refuses it (GHSA-44p2-whvx-9gwq).
Migration: new repositories no longer let anonymous users push. To allow
it, grant it explicitly, for example r.SetCaps("nobody", "gijoryz"), or
give clients an account with a password. Existing repositories keep the
capabilities already stored in them.
[0.10.0] - 2026-10-03
Changed
Checkout.Revertkeeps a never-committed file on disk when it reverts the add (#234). It used to delete the file, destroying the only copy. It now un-manages it and leaves it in place, as fossil'srevertdoes.Revertalso checks the disk first, so an edit that no scan has recorded yet is reverted instead of silently skipped; restores a file that is missing from disk; and, when reverting everything, drops a pending merge completely (merged-in versions and the merge record), as fossil does.Checkout.Renamerefuses to move a file onto one that already exists on disk, asfossil mv --harddoes, before changing anything. It used to overwrite it.
Fixed
Checkout.Revertundoes a pending rename, as fossil'srevertdoes (#242). It used to leave a rename made withfossil mvin place, and restored a renamed, edited file's content under its new name. The file now goes back to its old name with its committed content, and either name can be given. When undoing a rename would land on a name another file now holds (a swap, a new file added under the old name, or an untracked file there),Revertrefuses and changes nothing rather than overwrite it: go-libfossil has no undo copy to recover it from (#248). Naming several files reverts them as one: if one is refused, none is reverted.A dry-run
ExtractorUpdateno longer changes the checkout (#236). Both used to replace the checkout's file list and move its version to the target, losing pending adds, renames and merge state, even though no file was written. Switching versions withExtractnow also ends any pending merge, as fossil'scheckoutdoes, so a forced switch can't leave a merge record behind to give the next commit a bogus merge parent; re-extracting the current version withoutForcekeeps it. A forcedExtractstarts the checkout's file list fresh, asfossil checkout --forcedoes, so a pending rename or removal no longer survives it.A database fault while checking whether content is present no longer reads as "content missing" where missing content is tolerated (#239). Since #231,
CreateCheckouton a partly synced tip leaves the checkout's file list empty instead of failing; a busy or failed database during that check was treated the same way, soCreatequietly succeeded with an empty checkout. The fault is now reported. Newcontent.CheckAvailableByUUIDreturns the error; the existing boolean checks still treat one as unavailable.The CLI's
add,rm,renameandrevertcommands call the library instead of writing the checkout database with raw SQL (#234).renamenow moves the file, likefossil mv --hard, instead of leaving it under its old name where the next commit couldn't find it.revertrestores committed content instead of only clearing change flags. Errors, including from closing the checkout, are reported instead of ignored. Without-Rthey find the repository the checkout records, including a relative path asfossil openstores it.The internal stash no longer drops files changed by a merge (#233). It selected only
chnged=1, leaving out fossil's merge states, and stashed a rename as a plain edit. It now selects changes the same wayStatusdoes, and refuses a pending merge, a permission change or a rename by name, since it can't yet stash those faithfully. The stash isn't reachable through the public API yet. Checkin and stash now read the checkout through one internal package,internal/vfile, which is the only place fossil's checkout encoding is interpreted.Statusreports tracked files that are gone from disk asmissing, as fossil'schangescommand reports MISSING (#232). They used to be left out, or shown asaddedormodified.HasChangesnow sees a pending add made by the fossil binary without a scan first. Both now share one classifier, so they can no longer disagree about what counts as a change, and Status lists files by name. A directory where a tracked file belongs is reported asmissingrather than failing the scan. A checkout written by an older go-libfossil may still holdrid=0rows for content that was missing from the repository (see #231);HasChangesnow counts those, andExtractof a complete version replaces them.Checking out a version whose content is partly missing, as a partial sync leaves it, is now refused up front with an error naming the files (#231). go-libfossil used to record such files with
rid=0, which in fossil's checkout format means "added", andExtractthen panicked trying to read them. This matches fossil, which refuses with "missing content". ACreateCheckoutwhose tip is still partly synced no longer loads the tip's files, so extracting an older, complete version still works.A non-forced
Extractnow checks before it changes anything (#230). It used to replace the checkout's file list first, so a refusal lost pending adds, renames and merge state and leftStatusseeing an empty checkout. It also compared disk with the target version, so a clean checkout could not switch to a version that changed a file. Extract now follows fossil'scheckout: it refuses while the current version has unsaved changes that the switch would lose, or when an untracked file sits where the target would write different content. A tracked file that is missing from disk no longer blocks a switch, as in fossil.Reading a stored artifact could return compressed bytes (#235).
blob.Loadtreated a row as stored uncompressed whenever its stored length equalledblob.size. For a delta row those are different things (the compressed delta and the full artifact's length) and can be equal by chance, so the delta step then failed. Fossil always inflatesblob.content, and so does go-libfossil now. Go 1.27's new compressor made one test fixture hit the case, but any repository could.Checkouts created by the fossil binary (
fossil open) now work throughOpenCheckout(#228).Statusfailed outright withconverting NULL to stringonvfile.mhash, because go-libfossil judged every file against that column, which fossil only fills while a merge is pending. Change detection now follows fossil's own scan: a file is compared with the artifactvfile.mridnames, adds and merge states move through the samechngedtransitions, and fossil adds, renames andorigname==pathnamerows are classified the way fossil classifies them.A non-forced
Extractno longer overwrites uncommitted work in a fossil-made checkout. The local-edit guard swallowed the same NULL scan error and skipped its check, and it never considered a merge that fossil had applied but not yet committed; both now refuse unlessForceis set.Addwritesvfile.islink, which fossil's checkout schema declares with no default, so a file added by go-libfossil to a fossil-made checkout reads back cleanly in both tools. New rows no longer populatemhash.
[0.9.0] - 2026-08-07
Fixed
- New artifacts are named with the algorithm the repository's
hash-policyselects, rather than always SHA1. Committing into a repository whose policy is sha3 -- thefossil initdefault since 2.10 -- wrote SHA1 artifact IDs, so every F-card in the new manifest carried a differently-derived hash from the one already in the parent manifest andfossil diffreported every tracked file as CHANGED even when one file had been edited. Repository integrity was never affected and canonical fossil could always read the result; the damage was unreadable diffs and a permanently mixed-hash repository, plus artifacts ashun-sha1peer would reject.
Content already stored under the other algorithm's name is reused rather
than stored a second time, unless the policy forbids it (sha3-only,
shun-sha1). Without that, a repository holding SHA1 artifacts under a
sha3 policy would churn every F-card on its next commit -- the same
every-file-CHANGED symptom, mirrored. Only a stored blob qualifies: a
phantom is a name the repository knows of but has no content for.
checkout.Manage and uv.Write read the same policy instead of each
guessing from the width of an existing name. uv.Write's guess read
project-code, which is 40 hex characters in every repository whatever the
policy, so it could only ever choose SHA1.
- Repositories created by this library are seeded with
hash-policy, so they name artifacts the wayfossil initdoes. Without the row, naming derived the algorithm from the repository's own artifacts, and a repository with no artifacts yet settled on SHA1 -- then stayed there permanently, because the first artifact it wrote was SHA1 too.
A clone is the exception and keeps no seeded policy: it adopts its source's,
which the absent row expresses by deriving the algorithm from the artifacts
the clone actually receives. Canonical fossil propagates the source's policy
value into a clone; the sync protocol carries no configuration, so this
reaches the same answer by inference. The two differ only for a source
explicitly set to sha1 that nonetheless holds a SHA3 artifact.
Added
db.HashPolicySHA1,db.HashPolicyAuto,db.HashPolicySHA3,db.HashPolicySHA3Onlyanddb.HashPolicyShunSHA1name Fossil'shash-policyvalues, so the seed path and the naming path read one definition instead of each carrying its own copy of the enum.
Changed
- Repositories created by this library now name their artifacts SHA3 rather
than SHA1. No API changed, but the same content committed into a newly
created repository produces a different artifact ID than it did in 0.8.1.
Existing repositories are unaffected: their artifacts keep their names, and
their own
hash-policycontinues to decide how new ones are named.
[0.8.1] - 2026-07-30
Fixed
- Crosslink reports the underlying cause when it fails, instead of replacing it with a generic message.
- Test fossil servers are reaped before their temporary directories are removed, and the unit-test timeout was raised; leftover servers holding files open made teardown flaky.
Changed
- Generated and local-only files are no longer tracked.
[0.8.0] - 2026-07-29
Fixed
mlink,plink,tagxrefandleafare derived the way canonical fossil derives them, in both the sweep and the commit path, including derivingleafby canonical's branch rule. mlink derivation, previously duplicated, is now unified in one place.- Tag propagation stops at branch boundaries instead of crossing them, and the three tag-apply paths share one propagation helper.
- Authenticated sync interoperates with canonical fossil.
- Clone retries a dropped exchange round instead of aborting.
- The project code is derived, and artifact visibility is written inside the storing transaction rather than after it.
- Ticket artifacts are crosslinked, and ordinary file blobs no longer emit a warning.
- Checkins cascade-linked while referencing unavailable blobs are deferred until those blobs arrive.
- Submodules are pinned to v0.7.0 rather than an orphaned pseudo-version.
Added
- The CLI selects its SQLite driver by build tag.
Performance
- Clone no longer re-seeks
blob.uuidper F-card, and no longer over-allocates delta output. - The post-sync crosslink sweep no longer re-examines the whole repository.
- Content received during sync is reused in crosslink instead of being read back.
[0.7.0] - 2026-07-27
Added
libfossil versionprints a single, stable, machine-parseable build identifier -- module version (or a-ldflags -X github.com/danmestas/go-libfossil/cli.buildVersion=...override for release builds), Go toolchain version, and platform -- and exits 0. Previouslyversionwas not a recognized command at all and fell through to the unrecognized-argument path. There is no global--versionflag:repo extractalready owns--versionas a command-scoped flag (the source version to extract), and kong's global flags are visible in every subcommand's context, so a root-level--versionwould collide with it.libfossil repo serveCLI command wires the already-publicRepo.ServeHTTPxfer HTTP server into the command surface. A repository created or cloned with this tool can now be served to a peer -- including a stockfossil clone-- without writing Go. Binds to127.0.0.1:8080by default; override with--addr host:port. Process interrupt (Ctrl-C/SIGTERM) cancels the same contextServeHTTPalready shuts down on; no new shutdown mechanism was introduced.
Fixed
- The xfer HTTP server now sends an explicit
Content-Lengthon every clone and sync reply. The full response is already materialised byEncodebefore the first byte is written, so its exact length is known for free; setting the header keepsnet/httpfrom silently switching toTransfer-Encoding: chunkedonce the buffered body passes its internal flush threshold. Release-tagged fossil ≤2.23 reads the reply length only fromContent-Length(src/http.cleaves itsiLengthnegative otherwise) and aborts with "server did not reply" on any chunked reply above ~256KB, so those clients could not clone a repository larger than that from a libfossil server. No buffering was introduced — the response was always fully in memory — so #88's per-clone bound (DefaultCloneBatchBytes) still caps each reply. The fix is unconditional rather than negotiated: because the server never streams, chunked framing offers no memory saving overContent-Length, andContent-Lengthis what canonical fossil's own server emits, so it is strictly the more compatible framing for every client (issue #101). - The message decoder now selects a body's framing from its §4 Content-Type
rather than by trying each framing and seeing which parses. A body sent as
application/x-fossilis decoded as the §4.1 compressed container; every other type, includingapplication/x-fossil-uncompressed(used by clone-v3 replies), is decoded as plain card text — the same rule canonical fossil applies. Trial-based dispatch made a decompression failure indistinguishable from "wrong framing"; a compressed body that now fails to inflate is reported as the fault it is and never re-fed to the card parser. The former unprefixed raw-zlib framing was removed: it was checked before the prefixed container on the belief that real fossil emits it, but a fossil 2.28 server never does — its pull replies are the prefixed container and its clone replies are plain text — so the framing was only ever right by accident. Removing it also retires the length-prefix/zlib-header aliasing guard, which existed solely to disambiguate the two compressed framings that trial-dispatch conflated.xfer.Decodenow takes the Content-Type; call sites that carry no header (the byte-oriented NATS transport, both ends libfossil) pass the compressed type explicitly, matching what they always emit. The HTTP server rejects a request body whose Content-Type is absent or neither §4 type with 415 Unsupported Media Type instead of decoding it as plain card text: bodies come from untrusted peers, and without a framing signal decoding either way is a guess. Real fossil always sets the header, so no conforming peer is affected. (#106) - A large artifact now clones regardless of its position within a clone round.
The server charged its batch budget before each artifact and sent the one
that crossed it whole, so a round carried
budget + one whole artifact— and when ordinary sub-budget filler preceded a large artifact, that sum exceeded what the client could decode (xfer.MaxDecompressedBytes), failing the clone with a spurious "declared container length exceeds" error. The size at which a clone failed depended on incidental filler: 8–15 MB of filler ahead of a 58.7 MB artifact all failed, while 17 MB passed by pushing the artifact into a round of its own.emitCloneBatchnow flushes the round and sends an over-bound artifact alone when adding it would cross the client's decode bound, so the ceiling is a property of the artifact alone. The2 * budgetcompile-time guard now certifies what it appears to: filler plus one budget-sized artifact always fits in a decodable round. (#109) - A clone from a libfossil server could fail with a card-syntax error
(
decode card 16303: empty line after split) naming a card the response never contained. The message decoder tried three body framings in order and treated any decompression failure as "not this framing", so a well-formed compressed body that exceeded the decoder's own size bound fell through to the uncompressed branch and the still-compressed bytes were parsed as card text — thousands of nonsense cards until one split into no fields. Worse than the misleading error, an oversize or truncated compressed body was accepted: the garbage cards decoded without error and the artifacts they carried failed their hash check downstream. A body recognizable as zlib is now decompressed or reported, never reparsed. The bound is also raised to 64 MiB, since it sat below what this implementation's own server emits in a single clone round — two libfossil peers could not reliably clone from each other even though each could clone from fossil. A compile-time guard keeps the bound at least twice the server's clone batch budget, so filler plus one budget-sized artifact always fits in a round the client can decode. (#104) - Committing a file with zero-length content panicked in
blob.Store, which treated an empty artifact -- a normal, well-known Fossil blob -- as invalid input; the panic then triggeredmanifest.Checkin's postcondition defer, which re-panicked with an unrelated "manifestRid must be positive" message that masked the real cause.blob.Storenow accepts zero-length (and nil) content, andCheckin's defer re-panics with the original panic value instead of asserting a postcondition that was never going to hold mid-unwind (#68). - A commit no longer silently carries a tracked file's last-committed
content forward when that file has been deleted from disk without
Unmanage/Remove-- it now fails clearly, naming the file, matching fossil's own behavior for a missing file that is in scope for the commit being made. A missing file outside the commit's scope (relevant only to an explicitEnqueue) is left untouched exactly as before (#79). - Clone deadline now interrupts the mid-round phantom-fill crosslink cascade, ensuring the clone does not stall with phantoms outstanding. The same deadline is now threaded into the general push/pull phantom-fill cascade for consistency.
- Sync replies are now decoded by their wire's real
Content-Typeinstead of a hardcoded assumption. The HTTP server sends the correct type on each response; call sites receiving frames without a header (the byte-oriented NATS transport) pass the type explicitly. - PGP/SSH clearsigned manifests are now accepted: the deck module strips the clearsign framing before verification.
- Manifest now emits delete rows for files omitted by the check-in, matching fossil's own behavior. Renames now resolve their parent blob from the pre-rename path to ensure the rename-source blob exists and is reachable.
Changed
- Breaking: The module path has changed from
github.com/danmestas/libfossiltogithub.com/danmestas/go-libfossil. Allreplacedirectives ingo.modhave been dropped. Consumers must update their imports and remove anyreplacedirectives pointing to the old path. - Breaking:
StatusOpts,MergeOpts, andCheckoutOptshave been removed. No function anywhere accepted any of the three, and nothing constructed one:Checkout.Status()takes zero arguments,Repo.Mergetakes positional string arguments, andCheckoutOptshad no construction site at all. A public type with no call site documents a capability that does not exist — a consumer readingMergeOpts{Strategy: ...}in the docs could reasonably conclude a strategy-selecting merge API exists; it never did.Repo.MergeandCheckout.Statuskeep their current signatures — an options-struct refactor forMergewas considered and declined.CheckoutOpts.Forcewas also a third same-namedForcefield in this package, alongside the already-removedUpdateOpts.Forceand the real, fully-wiredExtractOpts.Force; removing it also removes that readability hazard.ExtractOpts.Forceis unrelated and unaffected. - Breaking:
CloneOpts.ProjectCodeandCloneOpts.ServerCodehave been removed. Neither was ever wired to anything —Cloneaccepted both fields but never forwarded them to the internal clone path, so setting either had no effect and gave no error. The internal clone options struct has no matching fields at all, so wiring them would mean designing semantics first (is a caller-supplied code a validation assertion or an identity override?) and no caller has asked for that.CloneResult.ProjectCodeandCloneResult.ServerCodeare unrelated and unaffected — they continue to report both, populated from the remote via the clone protocol negotiation, which is the only place they meaningfully originate. - Breaking:
UpdateOpts.Forcehas been removed. It was never wired to anything —Checkout.Updateaccepted the field but never forwarded it to the internal update path, so setting it had no effect and gave no error. Deleting it is honest about what the API actually does; real forcing semantics forUpdatecan be designed and added later as a new, deliberately-wired field if a caller needs them.ExtractOpts.Forceis unrelated and unaffected — it is fully wired and unchanged. - Breaking:
Checkout.Updatenow returns(UpdateResult, error)instead of a bareerror. The internal 3-way merge already tracked which files were written, removed, and left with conflict markers; the old signature discarded all of it, so a caller could not tell a clean update from one that silently wrote<<<<<<<conflict markers into working-tree files.UpdateResult.Conflictedlists the paths that were merged but not cleanly — this is a successful update (err == nil), not an error; a genuine failure still returns a non-nilerrorwith a zero-valueUpdateResult.Checkout.Extractis unchanged. - Breaking:
Repo.Timelinenow enumerates the repository'seventtable newest-first (every event kind by default, or a single kind viaTimelineOpts.Type), matching canonicalfossil timeline. The previous behavior — a first-parent walk from a required start rid — is preserved under a new, honestly-named method,Repo.Ancestry(LogOpts). The oldRepo.Timeline(LogOpts)was actually an ancestry walk masquerading as an enumeration: it never visited a second parent or a sibling branch head, so it silently omitted any check-in that wasn't a first-parent ancestor of the given start rid. There is no deprecated shim; callers of the oldTimelineshould switch toAncestryif they want the walk, or adopt the newTimeline(TimelineOpts)if they want a full enumeration. LogEntrygains aKindfield (EventKind) identifying which ofevent.type's six kinds (ci,e,f,g,t,w) an entry is.Parentsis only populated forKind == EventKindCheckin.- Breaking:
Repo.Timelineorders by(mtime DESC, rid DESC), a total order with rid as a true tie-break at exact mtime equality — a deliberate improvement over canonical fossil's baremtime DESCwith no tie-break, which can repeat or skip rows at a page boundary. Pagination uses a new opaqueCursortype: take one from a returnedLogEntry.Cursorand pass it back asTimelineOpts.Afterto resume immediately after that entry.TimelineOpts.Before time.TimeandTimelineOpts.After FslIDare deleted, not deprecated — callers constructing either field will fail to compile.Cursor's representation is intentionally hidden — it can only be obtained from aLogEntry, never built from a timestamp and a rid by hand, because a hand-built cursor derived from a roundedtime.Timeis not guaranteed to match its row exactly, which is what reintroduces skipped or duplicated rows at a page boundary in the first place. - Breaking:
LogEntryno longer has aCursorfield, andRepo.Timelinenow returns[]TimelineEntryinstead of[]LogEntry.LogEntryisRepo.Ancestry's result type; its cursor was always the zero value, which is structurally identical to a legitimate Timeline first-page call — so feeding anAncestryentry's cursor intoTimelineOpts.Aftersilently paginated from page one forever with no error, and documenting the field as invalid onAncestryentries did nothing to stop it.TimelineEntry(which embedsLogEntryand addsCursor Cursor) is nowRepo.Timeline's result type, so the cursor lives only on the type that has one to give. Callers that readentry.Cursor,entry.UUID,entry.User, etc. off aRepo.Timelineresult need no source change — those fields are still reachable throughTimelineEntry's embeddedLogEntry. Callers that stored aRepo.Timelineresult as[]LogEntry(rather than letting:=infer the type, or using[]TimelineEntry) will fail to compile. - Performance improvements throughout the sync and manifest subsystems: the
phantom-fill crosslink cascade now batches onto a shared content cache,
db.Queriercaches prepared statements to stop per-call SQL re-parse overhead, and delta-chain walks bound their hash-collision chain to prevent quadratic behavior on low-entropy input. History-walking expansions are routed through the content cache, cutting sub-64KiB delta-chain expansion allocation ~18x and boundingApplygrowth per-command to cut ~5x allocation overhead.
[0.6.3] - 2026-05-13
Fixed
- The SQLite DSN now sets
_txlock=immediate, so writers serialize atBEGINrather than discovering the conflict partway through a transaction (#33).
[0.6.2] - 2026-05-12
Fixed
Repo.Commitnow preserves the parent check-in's tracked files, instead of dropping files the commit did not itself touch (#30).
[0.6.1] - 2026-05-12
Added
CreateOpts.ProjectCodeaccepts an explicit project code when creating a repository, rather than always generating one (#31).
[0.6.0] - 2026-05-05
Fixed
- The WAL is checkpointed on
Close, so a repository written by this library is readable by canonical fossil without a recovery pass (#28).
[0.5.0] - 2026-05-04
- Added support for whole-checkin diff via empty filePath parameter.
- Added
Repo.UUIDFromRIDfor public rid-to-uuid lookup. - Added cross-repo release automation specification and implementation.
- Added release workflow with downstream dispatch for automated releases.
[0.4.5] - 2026-04-30
Fixed
- A clone against a hub that is being written to concurrently now converges, instead of looping without making progress (#17).
[0.4.4] - 2026-04-27
Fixed
- A partial xfer no longer panics with
rid=0; the incomplete transfer is handled rather than dereferenced (#14).
[0.4.3] - 2026-04-26
- Stabilized xfer encoder and multi-round sync with deferred manifest crosslink.
- Added
libfossil versioncommand to print build identifier including module version, Go toolchain version, and platform. - Wired live documentation URL into README and hugo baseURL.
- Added SVG architecture diagram and SDK refresh.
- Launched documentation site and Cloudflare auto-deploy.
[0.4.2] - 2026-04-26
- Fixed manifest crosslink deferral to handle referenced blobs arriving later.
[0.4.1] - 2026-04-25
- Implemented xfer encoder and multi-round sync foundation with manifest crosslink.
[0.4.0] - 2026-04-25
- Added
Repo.Pullpublic wrapper with HTTP transport and hostile-input assertions. - Added Tiger Style test coverage for
Checkout.Update. - Improved documentation and test coverage for
Repo.Pull.
[0.3.0] - 2026-04-21
- Added
Repo.ReadFilefor reading tracked file content from the repository at any version.
[0.2.0] - 2026-04-20
- Added
Repo.Mergefor branch-to-branch 3-way merge operations. - Added
Repo.Difffor computing diffs between tree states.
[0.1.0] - 2026-04-20
Initial open-source release of libfossil, a pure-Go implementation of the
Fossil SCM that reads and writes the same .fossil SQLite repository format.
Added
- Repository lifecycle: create new repos and clone from existing ones.
- Working-tree operations: checkout and checkin.
- Timeline traversal over commits and events.
- Merge and rebase primitives.
- Diff and annotate (blame) over tracked content.
- Manifest parsing and content-addressed blob storage.
- Sync protocol client/server for pulling and pushing between repos.
- Observer interfaces for sync and checkout, allowing external hooks into both network sync events and working-tree state transitions.
- SQLite driver abstraction with support for both
modernc.org/sqlite(pure Go) andncruces/go-sqlite3(cgo-free, wasm-based) backends. - Deterministic simulation test harness with BUGGIFY-style fault injection for exercising concurrency and failure paths.
- OpenTelemetry observer provided as a separate submodule to keep the core dependency footprint small.
wasip1/wasmbuild target for runninglibfossilunder WASI runtimes.