Skip to content
go-libfossil Timeline
Login

Timeline

50 descendants and 50 ancestors of 3a1014cf60ad4b9f

2026-10-13
02:23
test(conformance): check the -01 drafts of artifact, repo-state and sync

Re-pin the conformance suite to draft-fossil-artifact-format-01, draft-fossil-repo-state-01 and draft-fossil-sync-protocol-01, copied byte for byte from zit check-in 100351e26c. The delta format has no -01 and stays on -00. The -00 errata are folded into -01 and are no longer pinned; the six -01 errata are pinned for the fossil 2.28 behavior they record. conformance/rfcs/README.md records where each copy came from.

The requirement tables are re-read against -01, and new cases cover the date tag's event time (repo-state §4.2 step 6), the Julian-day grammar, a delta manifest naming a delta, dropped reserved F cards, the trailing-punctuation retry, truncated payloads, errors N, a file card after a refused push, the end of a request after a failed login, and the answer to a gimme for private content.

No library behavior changes. Where -01 departs from fossil 2.28 and go-libfossil follows fossil (a cancelled, unparsable or 0 date tag), the cases are marked as deviations. The non-conformances the re-pin exposes are marked as gaps, tracked in #370, #371, #372, #373, #374 and #375. leaf check-in: 8cc3d7c5e4 user: [email protected] tags: test/conformance-01-drafts, trunk

01:52
fix(sync): gate xtable-hash on a direction card (#351) (#369) check-in: b2d5659397 user: [email protected] tags: main, trunk
01:46
fix(sync): keep the client's gimme limit as fossil's mxPhantomReq (#350) (#368) check-in: 46fa73b523 user: [email protected] tags: main, trunk
01:40
fix(sync): server igot handling skips shunned hashes (#349) (#367) check-in: 1724e7ec32 user: [email protected] tags: main, trunk
01:10
fix(testutil): isolate fossil's global config in tests (#284) (#366)

* test: isolate fossil's global config in tests that run the fossil binary (#284)

Tracked in #284.

* test(testutil): document the file-level scope of the fossil home guard (#284)

* test(testutil): allow StartServer in the fossil home guard (#284) check-in: 8d3f18265e user: [email protected] tags: main, trunk

00:48
fix(test): kill fossil server's process group before cleanup (#352) (#365)

Start fossil server in its own process group through a shared testutil.StartServer helper and SIGKILL the whole group before Wait, so forked per-connection children cannot write into t.TempDir() during cleanup. Tracked in #352. check-in: bb864e6c58 user: [email protected] tags: main, trunk

00:38
fix(tag): apply bgcolor, user and comment tags to event as fossil does (#343) (#364)

* fix(tag): apply bgcolor, user and comment tags to event as fossil does (#343)

tag.ApplyTagWithTx now sets event.bgcolor, euser and ecomment from the bgcolor, user and comment tags for every tag type, NULL on a cancel, as tag_insert does. Crosslinking a check-in reads the three values back from tagxref, as manifest_crosslink does, so a tag that arrived while the check-in was a phantom takes effect.

Tracked in #343.

* fix(tag): store empty event override values as NULL (#343) check-in: 66cd641642 user: [email protected] tags: main, trunk

00:17
fix(sync): let Clone imply Read at the server's read gates (#312) (#363)

fossil's trunk page_xfer sets g.perm.Read for a requester with Clone, and the section 7.3 erratum states it for uvgimme. The handler treated only o, i and Admin as Read, so a Clone-only requester got no unversioned files, no xtable-hash inventory and no read-gated configuration.

This is an access change. handler.canRead, CanRead or a Clone held before any login (as page_xfer derives Read ahead of its card loop), now owns every read gate: the unversioned catalog for uv-hash and for a clone, uvigot and uvgimme answers, xtable-hash, and reqconfig. auth.CanRead is unchanged, and pull still needs o. The reqconfig interop test compares a Clone-only requester with fossil 2.28 given Read too, since the 2.28 binary predates the trunk change.

Pull still needs o although fossil trunk's derived Read lets a Clone-only nobody pull; that difference is deliberate. check-in: a67fe49556 user: [email protected] tags: main, trunk

00:12
fix(sync): log in through the x-f-l-c cookie and accept cleartext passwords (#304) (#362)

fossil's client moves its login card from the body into the x-f-l-c cookie once the server's server-version passes xfer_xflc_check (2.27, 2025-07-27 11:05 or later), and fossil's check_login, when a stored password is not 40 characters, tries the signature again against the shared secret that cleartext password derives (sections 6.4, 6.5). The client always logged in in the body, and the server checked only the stored value.

A request marks itself LoginCookieOK once the gate passes; Message.SplitLoginCookie moves its login card out of the body, and the HTTP transports (sync.HTTPTransport and the public NewHTTPTransport through the adapter) send it as the cookie, on every redirect too. The nonce is unchanged: it already covers what follows the login line, which in cookie form is the whole body. Transports that cannot set a cookie send the login in the body as before. The server side of the cookie landed with the server-version exchange.

HandleOpts gains LoginCookie, so an embedder serving HandleSyncWithOpts over its own HTTP handler can pass the cookie through; XferHandler and ServeHTTP read it themselves. check-in: 9d35c01fb3 user: [email protected] tags: main, trunk

2026-10-12
23:24
fix(sync): apply received unversioned files as fossil does (#308) (#361)

fossil's xfer_accept_unversioned_file stores a received file under the HASH its card gave, takes only the mtime when the content is the same and newer (status 2), and stores an omitted payload as NULL with size 0; send_unversioned_file sends nothing for a name it does not hold; and the client seeds uv_tosend from rows whose hash is not NULL (sections 7.3, 8.5.1). Both sides stored a received file under a hash computed by local policy and dropped status 2 and omitted-content cards; the server answered an absent name with a tombstone; the client queued its tombstones.

uv.Receive now owns the acceptance rule, which the server and the client share instead of each re-deriving it from uv.Read, uv.Status and uv.Write. check-in: d33a37fffd user: [email protected] tags: main, trunk

23:20
fix(sync): send clone replies as fossil's page_xfer does (#306) (#360)

fossil's send_compressed_file sends a cfile as the blob table stores it and pads a payload that does not end in LF; page_xfer sends a version 3 clone's reply as application/x-fossil-uncompressed, sends the unversioned catalog with a clone when uv-sync is on, and refuses a clone with a reset, the push card and "error not authorized to clone". The server recompressed each cfile, never padded, compressed the reply, sent no catalog, appended its refusal to the reply, and restarted a clone whose SEQNO was negative.

CFileCard's StoredBlob, set by the clone path from the blob table, is now what the encoder sends; xfer.Message.Uncompressed tells the HTTP server to send a reply as card text. The catalog goes to a requester with Read, as for uv-hash: fossil lets Clone imply Read there, which this server does not. A negative SEQNO, which section 8.1 exempts from its fatal, now ends the clone with clone_seqno 0, and the unit test that expected a restart expects that. check-in: 6c69fc1231 user: [email protected] tags: main, trunk

23:14
fix(sync): answer pushed config, ci-unlock and req-clusters (#305) (#359)

fossil's page_xfer refuses a pushed config card from a requester without Admin with a reset, "error not authorized to push configuration" and a break (section 9.3); drops a client's check-in locks on pragma ci-unlock (section 9.4); and answers pragma req-clusters with send_all_clusters, the cluster igots between "# sending-clusters" and "# end-of-clusters" (section 5.3). The server ignored the first two and recorded the third without answering it.

An Admin's config push is refused too, naming that this server does not apply pushed configuration, rather than dropped while the push appears to succeed. ci-unlock is honored for writers only, as ci-lock already is, and the cluster igots go to any requester, as in fossil: they name public artifacts and carry no content. check-in: 85c48f38eb user: [email protected] tags: main, trunk

23:10
fix(sync): exchange server-version and gate SHA3 content by peer release (#303) (#358)

fossil's page_xfer answers a client-version that carries DATE and TIME with pragma server-version, and both sides record the peer's release; send_file, send_compressed_file and send_unversioned_file then append "error Fossil version 2.0 or later required." in place of SHA3-named content for a peer older than 2.0, with a server treating a client that announced no release as one (sections 7.6, 9.1). The server sent no server-version and neither side kept or gated on the peer's release.

The server gates gimme, clone and uvgimme; the client gates its answer to a gimme. Because a server-version from a current release turns fossil's client to logging in through the x-f-l-c cookie (section 6.5), the HTTP server now reads that cookie: xfer.DecodeRequest puts its login card first with the nonce bound to the whole body, as page_xfer does. Without it a real fossil client's later rounds arrived as nobody. That moves sync.6.5.server-cookie-login, marked for #304, here. check-in: 6963b0a9dc user: [email protected] tags: main, trunk

23:06
fix(sync): end client sessions by section 3.7 and keep igot phantoms (#301) (#357)

fossil's client_sync continues a round when stored content or a new phantom leaves the phantom table non-empty, or when files are queued for the next request (section 3.7). The client instead continued on any received file and on any phantom until it had aged three rounds, kept a gimme it could not answer queued forever, and kept an igot's phantom only in the session's map. After a clone's first round it aborted on a server error, where the draft ends the reply-card loop and goes on.

converged now applies 3.7 (a) and (b). A gimme is queued only for content the client holds and may send. An igot's phantom is written to the phantom table, batched once per reply as content_new would write it, and the after-dephantomize cascades of a reply run as one batch (manifest.AfterDephantomizeBatch), so a pull no longer begins a crosslink transaction per artifact. A clone records a later round's server error, stops reading that reply, continues by 3.7, and fails at the end naming the error. Four unit tests that encoded the old continuation now expect fossil's. check-in: 62683d1d0f user: [email protected] tags: main, trunk

23:01
fix(sync): send pragma send-private first and keep a private basis private (#307) (#356)

fossil's client_sync emits pragma send-private ahead of client-version in its first request and right after it in later ones (section 3.2); the client put it after its igot, file and gimme cards. And fossil's content_new makes the missing basis of a private card's delta a private phantom (section 7.4), where the client made it public.

buildRequest is split into header, content and extra card builders so the header can carry send-private in fossil's place; the cards it sends are otherwise unchanged. check-in: 9d3644134d user: [email protected] tags: main, trunk

22:57
fix(sync): drop shunned artifacts and never ask for a shunned phantom (#302) (#355)

* fix(sync): drop shunned artifacts and never ask for a shunned phantom (#302)

fossil's xfer_accept_file and xfer_accept_compressed_file drop a received artifact whose hash is on the shun list, and its request_phantoms and content_new leave shunned names out of what a client asks for (sections 3.3 and 7.7). The server, the sync client and clone stored a shunned artifact, and the client asked for shunned phantoms.

storeReceivedFile, which every receive path shares, now drops a shunned artifact; the server checks first so a dropped file is not reported as missing after store. The client's phantom scan and its igot handling skip shunned names, so a delta whose basis is shunned is kept as a delta and the basis is never requested.

* refactor(sync): split the client's reply handling into per-card helpers

processResponse had grown to 232 lines: one switch over every reply card followed by the convergence check. It is now a loop over handleReplyCard, which hands each card to a focused helper, and converged, which keeps the termination rules apart from card handling. Behavior is unchanged. check-in: aea957939f user: [email protected] tags: main, trunk

22:52
fix(sync): take a uvfile on y alone, without a push card (#296) (#354)

fossil's page_xfer accepts a uvfile on the unversioned-write capability (WrUnver, y) alone; `fossil uv sync` sends uvfile cards with no push card. The handler refused each with "rejected: no push card", so pushing unversioned files from fossil to a go-libfossil server stored nothing.

This is an access change: a requester with y and no Write may now store unversioned files, as in fossil. A refusal (no y, or a HASH that does not match the content) now resets the reply, answers the error and breaks out of the card loop, as xfer_accept_unversioned_file's caller does. Tests pin both sides of the gate, and an interop test pushes with fossil uv sync. The real-fossil test helper now points HOME and XDG_CONFIG_HOME, not only FOSSIL_HOME, at the test's directory. check-in: b45ccf4351 user: [email protected] tags: main, trunk

22:48
fix(sync): refuse push and pull cards naming another project (#309) (#353)

fossil's page_xfer compares a push or pull card's PROJECTCODE with the repository's project-code before any capability check and, on a mismatch, resets the reply, answers "error wrong project" and breaks out of the card loop (section 8.5). The handler accepted either card whatever its project code.

The refusal goes through a new breakLoop, which the undecodable file card path now shares: the reply is reset to the one error, no later card is processed, and the post-loop work and timestamp still follow. Tests that synced repositories created with different project codes now create them with a shared one, and the DST simulator gives its leaves the upstream's. check-in: 821cf115ce user: [email protected] tags: main, trunk

20:17
fix(checkout): make WouldFork fossil's commit-time fork check (#347) (#348)

Since #291 WouldFork was fossil's leaf_ambiguity, which answers no at once for a version that is not a leaf. That is the case a commit forks in: when a pull brings in a child of the checked-out version, the version stops being a leaf, WouldFork said false, and the commit became a second child. EdgeSync's autosync relies on WouldFork to refuse that commit.

WouldFork is now fossil's commit refusal (src/checkin.c "Would fork", taken when !is_a_leaf(vid)): true when the version has a child on its own branch, read from plink as is_a_leaf does, merge children included. Another open leaf elsewhere on the branch no longer counts; fossil commits there, extending the checkout's leaf, and only warns afterwards.

The repo-state draft's Section 7.5 ambiguity test moves to checkout.LeafAmbiguous, and the repostate.7.5.ambiguity-* conformance cases call it. The superseded #291 changelog text and the WouldFork API doc are corrected. check-in: cd4a11828f user: [email protected] tags: main, trunk

19:37
fix(branch): check the open branch name in the check-in transaction (#316) (#346)

branch.Create refused a name an open leaf carries (fossil's branch_is_open), but checked before manifest.Checkin opened its transaction, and Checkin applied the branch tags, parent propagation and leaf state in later transactions. Concurrent creates of one name could all pass the check and all commit.

Checkin now derives its tags and leaf state inside its own transaction and runs an optional Precheck first in it. Create checks the name there, so check and write are one transaction: with BEGIN IMMEDIATE the second writer sees the first's open leaf and is refused. Create's redundant second tag pass after the check-in goes away. check-in: 9c071c7c22 user: [email protected] tags: main, trunk

19:21
fix(verify): rebuild derived state in one transaction (#335) (#345)

fossil's rebuild_db empties and re-derives the repository's tables in a single transaction. Since #285 Rebuild committed the emptied derived tables before the crosslink sweep, because the sweep found candidates outside any transaction and opened its own; a sweep that failed partway left event, plink, mlink, tagxref and leaf empty.

manifest.CrosslinkRebuild now runs inside the caller's transaction: candidate discovery, linking and the leaf/tag repair gate all on tx. Rebuild empties, re-derives and refills the bookkeeping tables in one WithTx, so a failure rolls back to the state it found. The incremental sweep keeps its batched commits; only the repair gate is factored out so both paths share it. check-in: 2832099f56 user: [email protected] tags: main, trunk

19:16
fix(manifest): set event.mtime from the newest date tag (#334) (#344)

fossil's check-in time override (`fossil amend --date`) is a one-shot date tag. manifest_crosslink takes a check-in's event.mtime from that tag's value when tagxref already holds one, and tag_insert moves event.mtime (keeping the original in omtime) whenever a date tag wins its tagxref row. Crosslink always used the D-card time, so every date-amended check-in sat at its original time.

Crosslinking a check-in now reads its date tag back, and tag.ApplyTagWithTx applies the override. tag.ApplyTag and tag.AddTag now derive through ApplyTagWithTx rather than repeating it, so a tag added directly moves event.mtime and event.bgcolor as crosslink would.

New fossil-referenced repository-state cases cover the tag after the check-in, before it (on a phantom), and newest-wins. check-in: 81b0b585e5 user: [email protected] tags: main, trunk

19:01
fix(sync): frame HTTP requests and replies as fossil's http.c does (#310) (#342)

The server refused application/x-fossil-debug with HTTP 415, where fossil dispatches it to sync like the other x-fossil types. The client read any 2xx as a sync reply and decoded a reply as compressed only when typed exactly application/x-fossil, so a reply with no Content-Type was read as plain cards and a proxy's HTML page was parsed as cards.

Replies now go through xfer.DecodeReply, which applies section 4's reply rule by case-insensitive prefix: no type is the compressed container, an x-fossil-debug or x-fossil-uncompressed prefix is card text, another x-fossil prefix is compressed, and anything else is refused. Only status 200 is a reply. check-in: 47dc6363c0 user: [email protected] tags: main, trunk

18:07
fix(sync): handle unknown, error and pull-only cards per the draft (#300) (#341)

The server ignored reply-only cards a client should never send and the client read on past cards that end a reply.

On the server, a client-sent error or message card -- reply-only cards fossil's page_xfer has no branch for -- now answers "bad command" and resets the reply, not silence. A push card from a requester who may read but not write answers "message pull only - not authorized to push", as fossil does, rather than a refusal, so the pull proceeds. To reach that branch the client now sends its pull card before its push card, fossil's order: the server's read-vs-pull-only decision turns on having seen the pull card first.

On the client, an error card ends the reply-card loop (§10.1): the cards before it stand, those after it are not read. A "message pull only ..." (§10.2) stops the client pushing -- it keeps pulling but answers no further gimme and drops its push card one request later, matching fossil clearing SYNC_PUSH after it has already emitted the next request's header.

Behavior change worth a look: a first --private pull against a server that denies private content (no 'x') now receives no content that session, because the denial error ends the reply-card loop before the server's inventory, as fossil's client does. The private blob still never reaches an unauthorized client. TestSyncPrivateEndToEnd/no_x_capability is updated to this. check-in: c9436f82cc user: [email protected] tags: main, trunk

18:01
fix(xfer): follow the draft's card grammar, not whole-message failure (#299) (#340)

The card decoder refused a card by failing the entire message: the server answered HTTP 400 where §5.2 has it emit "bad command" and parse on, and the client dropped a whole reply including its valid prefix.

The decoder now yields a bad-command card for a line no rule accepts (an unknown operator, a bare pragma, a gimme with a surplus token, a config with a signed size) and a bad-file card for a file/cfile/uvfile line it cannot frame. The server resets its reply, emits the matching error (bad command, or the handler's "malformed file line" and the like) and either parses on (bad command) or stops (bad file), as fossil's page_xfer does; the client ends the reply-card loop, keeping the cards decoded before the bad one; a clone treats it as a non-retryable protocol error.

Tokens are split on ASCII whitespace only, within fossil's six-token window (blob_tokenize over aToken[6]): a no-break space no longer separates tokens, and a seventh token is ignored rather than rejected, so igot and private tolerate a surplus token. A full cfile's USIZE is carried through as metadata, not checked against the inflated size (the hash binds the content instead), and the byte after a config payload is consumed whatever it is, both matching fossil's xfer_accept_compressed_file and page_xfer.

This also resolves the bad-command cases tracked in #300 (sync.5.2.bad-command, .bad-command-resets, .unknown-prefix, sync.10.1.bad-command-keeps-mutations), whose marks are removed here. check-in: ddc4b5396d user: [email protected] tags: main, trunk

17:52
fix(deck): enforce card legality, required cards and per-type semantics (#273) (#339)

deck.Parse determined an artifact's type but then checked nothing against it, so a card a type disallows, a required card left out, or a tag that breaks a per-type semantic rule all parsed.

Add deck/legality.go with fossil's manifestCardTypes table (allowed and required cards per type) and the §5.4 semantic checks: a control artifact's tags may not target itself, a technote's tags are all self-referential singletons, and a forum post carries at most one of an H or I card and at most one parent. Parse now determines the type (inferType gains the B, J and Q type-setters it was missing and falls back to a control artifact, not an empty check-in, when nothing sets a type and there is no comment or parent), then applies legality and the semantic checks. A missing Z card is now caught by the universal required-card rule.

Several internal/manifest and internal/deck tests built incomplete artifacts fossil also rejects -- a cluster with a D card, a forum post with both an H and an I card, control-shaped D/U/Z artifacts with no T card, and single-card fixtures missing their type's required cards. Frame each as a valid artifact of its type; the behavior each test pins is unchanged.

This also makes two repostate conformance cases conform: the technote with a non-self tag is now rejected at parse (not by a later isArtifact gate), and the opaque-control rebuild case. Their marks are removed in conformance/repostate_gaps_test.go. check-in: 7b8f655da6 user: [email protected] tags: main, trunk

17:44
fix(deck): re-encode present-but-empty values and keep timestamp precision (#272) (#338)

Marshal dropped a present-but-empty C comment, N mimetype or T value, a bare P card and an empty W body, and wrote D to the millisecond and E to the second, so an artifact carrying any of those did not round-trip through Parse and Marshal.

Carry the presence of a C, N or P card, and of a T value, in unexported Deck/TagCard fields that Parse sets and Marshal reads, and re-emit the W card whenever its body is non-nil (a parsed empty body is a non-nil empty slice). The public API is unchanged: a caller that sets a non-empty value still has it marshalled. Write the D and E timestamps at their writer precision -- millisecond and second -- extended to keep whatever finer fraction the parser read, so a sub-second timestamp survives the round trip. Remove the three FuzzArtifactRoundTrip knownLosses these fixes retire. check-in: 0902561005 user: [email protected] tags: main, trunk

17:39
fix(deck): parse D and E timestamps with the full 4.6.4 grammar (#271) (#337)

The parser tried only two time.Parse layouts and rewrote a lowercase t to T, so it rejected the HH:MM, timezone, hour-24, Feb-31 and negative-year forms fossil accepts via SQLite's julianday(), and it silently accepted a lowercase t separator the draft rejects.

Add deck/timestamp.go: a byte cursor that parses the date-time form (optional leading minus, YYYY-MM-DD, one or more whitespace-or-T separators, HH:MM with optional seconds and fraction, an optional Z/z or +HH:MM/-HH:MM zone), the date-only form, and a bare Julian-day number, with the exact-digit field ranges the 4.6.4 erratum scopes to those forms and the "strictly after -4713-11-24" lower bound. A lowercase t separator is rejected. time.Date normalizes the hour-24 and unchecked-day forms the way julianday() does. check-in: 127685f0f5 user: [email protected] tags: main, trunk

17:34
fix(verify): derive Rebuild state through the crosslink sweep (#285) (#336)

verify.Rebuild kept its own partial derivation of repository state: it wrote event/plink/mlink/tagxref rows only for check-ins and control artifacts, applied tags from artifacts the crosslink would refuse, skipped absent tag targets and parents instead of reserving phantoms, wrote no plink baseid, and computed leaves as "no child at all" regardless of branch. Against fossil rebuild it missed hundreds of event, plink, tagxref and leaf rows on real repositories.

Make Rebuild empty the crosslink-derived tables and run the same sweep a clone runs, so derived state has a single owner. The three partial derivation files (rebuild_tags.go, rebuild_manifest.go, rebuild_leaves.go) are deleted; Rebuild keeps only what the sweep does not do -- blob hash verification and the unclustered/unsent bookkeeping.

A rebuild is terminal: nothing more will arrive, so a check-in whose file or baseline content is absent must be linked with phantoms rather than deferred for a blob that never comes, as fossil's add_one_mlink does via uuid_to_rid(...,1). Add manifest.CrosslinkRebuild, a sweep that does not defer; on a complete repository it is identical to Crosslink. Rebuild's leaf rule now follows fossil's branch-aware sweep, so an untagged parentless check-in is no longer a leaf -- the same Section 7.3 deviation the sweep and per-artifact paths already carry. check-in: 33959a6e3e user: [email protected] tags: main, trunk

17:29
fix(deck): enforce the pathname, wiki-name and reserved-name rules (#269) (#333)

The parser stored F-card filenames and old-names, A-card filenames and targets, and L-card titles without the §4.6 name predicates, and never dropped a reserved repository filename.

Add deck/pathname.go with the three predicates ported from fossil: the non-strict simple-pathname check (file_is_simple_pathname_nonstrict), the well-formed wiki-name check (wiki_name_is_wellformed), and the reserved-name test (file_is_reserved_name). Apply the simple-pathname predicate to the decoded F filename and old-name and the A filename; the wiki-name predicate to the decoded L title and to an A target that is not an artifact hash; and drop a reserved F-card filename silently after the sort-order check, as fossil does. check-in: 119ef76ae9 user: [email protected] tags: main, trunk

17:24
fix(deck): validate every artifact-hash and checksum position (#268) (#332)

The parser stored hash-bearing tokens without checking them, so an F, B, E, G, I, K, M, P, Q, A-source or T-target token of the wrong length or alphabet passed, and the A target and N mimetype were kept raw rather than decoded.

Validate each position against the §4.7.1 map: 40 or 64 case-insensitive hex for an artifact hash, 40 for the K-card ticket id, 32 for the R checksum. A baseline manifest now requires a hash on every F card; only a delta manifest (one with a B card) may carry a hashless deletion card. Decode the A target and the N mimetype per §4.3.1 and re-encode them on marshal, keep the other hash tokens raw, and keep only the final path component of the A filename. Reject a Q sign other than + or -, and a tag name that is entirely hex in either case.

Four internal/manifest crosslink tests built manifests fossil itself rejects -- two R cards 40 hex long (R is 32), an attachment source "abc123", and a baseline manifest with a hashless deletion card. Make those fixtures valid artifacts (32-hex R, a real source hash, the merge expressed as a delta off its primary parent so the deletion card is legal); the mlink and crosslink outcomes they assert are unchanged. check-in: 1a15366e29 user: [email protected] tags: main, trunk

01:42
fix(deck): tokenize cards as fossil does and enforce per-card arity (#266, #267) (#331)

The parser split card arguments with strings.Fields and strings.TrimSpace, which collapsed a repeated space, split on tab and trimmed a trailing CR, and its line reader skipped blank lines. It also gave no card a maximum arity, so an extra token was absorbed.

Replace the line reader with a scanner that mirrors fossil's next_token/next_card: tokens are separated by a single space, a card ends at LF, and tab and CR are ordinary token bytes. A blank line, an empty token between two others, a bare card where a value is required, and residue after the last card are rejected; a line ending in a space keeps a present-but-empty last value. The W card's size is parsed as leading decimal digits with an overflow guard and the body must be newline terminated. The clearsign signature trailer is matched on 29 bytes, as fossil's remove_pgp_signature does, so a mangled final dash no longer hides it.

A faithful tokenizer enforces arity intrinsically -- fossil rejects an extra token as residue once the card's fields are read -- so #267 is resolved by the same change: each card is held to its exact Table 2 (§4.2.1) token count. The card parsers now take the scanner's tokens rather than one argument string; update the J-card and U-card unit tests to the new shape and to fossil's actual verdict on a multi-space U-card (rejected, not "anonymous"). check-in: 1af61c2713 user: [email protected] tags: main, trunk

01:37
fix(deck): judge artifact framing by the rule it breaks, not the Z checksum (#265) (#330)

deck.VerifyZ required a Z card at the last 35 bytes, read the final byte as the Z card's LF, and rejected a too-short input, so framing failures (a missing final LF, residue after the Z card, a short or wrapper-only blob) were all reported as checksum errors. Rewrite verifyZ to mirror fossil's verify_z_card: locate the Z card, never read byte n-1, treat an absent or unparsable Z card as "continue", and error only on a present-but-wrong checksum.

Restructure Parse into visible stages: a raw framing check (non-empty, ends in LF) on the bytes before the signature wrapper is stripped; the first-card line gate (upper-case letter, space); the Z self-checksum; then the card loop, which now scans the whole content and parses the Z card as an ordinary card rather than trimming a fixed 35 bytes. A missing Z card is judged by the universal required-card rule after the loop.

Update TestVerifyZTooShort, which encoded the old reject-on-short behavior, to expect fossil's: a too-short input has the Z card absent. check-in: 7aab5bfd8e user: [email protected] tags: main, trunk

00:31
fix(libfossil): sym- prefix and reserved-prefix refusal in Repo.Tag (#293) (#329)

fossil tag add places a check-in tag in the symbolic namespace with a sym- prefix, so it resolves as a branch/tag name in update and merge, and refuses the reserved wiki-, tkt- and event- prefixes. Repo.Tag stored the name verbatim. It now refuses the reserved prefixes and, when the target is a check-in (fossil's whatis_rid_type==CFTYPE_MANIFEST), prefixes sym-; other artifact types keep the name bare. The raw tag.AddTag primitive is unchanged. Callers pass the bare name; fossil prefixes the raw name, so sym-x becomes sym-sym-x on a check-in. check-in: b5c515e10f user: [email protected] tags: main, trunk

00:24
fix(tag): keep a propagated-tag cancel from deleting newer inherited rows (#288) (#328)

* fix(tag): keep a propagated-tag cancel from deleting newer inherited rows (#288)

The cancel branch of the propagation walk used COALESCE(srcid=0, 0), deleting any inherited row whatever its mtime. fossil's tag_propagate guards both the propagate and cancel cases with srcid=0 AND tagxref.mtime < :mtime: an inherited row newer than the tag is left in place and the walk stops there. Unify the two queries on that guard, parameterizing only the default for a child with no row.

* test(tag): cancel keeps newer inherited rows; bind the propagate default (#288)

Bind the per-type doit default as a query parameter instead of Sprintf-ing it into the SQL, and drop the redundant var rows/var err. Add unit coverage: a cancel skips an inherited row newer than itself and halts the walk there, and re-propagation at equal mtime keeps the first value. check-in: 58aabe243a user: [email protected] tags: main, trunk

00:18
fix(manifest): apply the parent (reparent) tag (#286) (#327)

* fix(manifest): apply the parent (reparent) tag (#286)

A singleton parent tag relocates a check-in's ancestry. fossil's manifest_crosslink_end rewrites the tagged check-in's plink and mlink to the hashes the tag names, before it propagates tags, so the leaf recompute sees the new edges and an old parent with no remaining child becomes a leaf. ReparentCheckins does this over a deck whose P-card is the override, reusing the normal plink/mlink builders. It runs from the crosslink repair gate and from verify.Rebuild, which had no reparent step.

* test(manifest): reparent plink/mlink/leaf, idempotence, phantom target (#286)

Also fold the plink baseid resolution into a shared checkinBaseid helper used by both the normal linkage path and the reparent pass, and note that the reparent repair is O(reparented) per round like fossil's manifest_crosslink_end. check-in: 088f1d8a90 user: [email protected] tags: main, trunk

00:13
fix(manifest): refuse a technote with a non-self or non-singleton T-card (#287) (#326)

fossil rejects a CFTYPE_EVENT artifact at parse time unless every T-card is a '+' tag on the technote itself; a technote may not tag another artifact, propagate, or cancel. go-libfossil's parser still accepts such a technote, so gate it in isArtifact: a technote whose T-cards are not all self-referential singletons is not an artifact and applies none of its tags. check-in: cf9089ec35 user: [email protected] tags: main, trunk

00:08
fix(manifest): make root check-in mlink derivation idempotent (#294) (#325)

* fix(manifest): make root check-in mlink derivation idempotent (#294)

The root-file branch of insertCheckinMlinks inserts each file row with no already-derived guard, unlike addMlink's pmid>0 early-out, so crosslinking a root a second time -- which the relink path does after clearing event and tagxref -- inserted a second copy of every row. Skip the block when a root row (pmid=0) is already present, matching the idempotence of fossil's manifest_crosslink.

* test(manifest): root check-in mlink derivation is idempotent on relink (#294) check-in: d5fdd30d12 user: [email protected] tags: main, trunk

00:03
fix(manifest): write the first check-in's sym-trunk as propagating (#289) (#324)

fossil new and repo-state §6.1 write *branch and *sym-trunk, both propagating, so the trunk alias descends to every check-in on the branch. The initial check-in built sym-trunk as a singleton (+), which stopped the alias at the root. check-in: 470f85d9fa user: [email protected] tags: main, trunk

2026-10-11
23:54
fix(hash): default to sha3 when empty and persist the auto promotion (#274) (#323)

With no hash-policy row, NamingFor now follows fossil's hname_default_policy: sha3 if the repo holds a SHA3 artifact or no SHA1 one, auto otherwise (7.2). hash.PromoteFor, called by blob's single row writer and by sync's received-content store before an artifact enters, writes hash-policy=2 when an auto repo takes a SHA3 name (7.4), as content_put_ex does, so fossil opening the repo sees the promotion. A row still saying auto next to SHA3 artifacts, from before the promotion was written, keeps deriving sha3. check-in: cc76fd023f user: [email protected] tags: main, trunk

23:44
fix(manifest): reject a non-baseline as a delta manifest's baseline (#275) (#322)

ListFiles took any parseable artifact named by a B card as the baseline, so a delta manifest or a control artifact produced a partial or empty file set. Sections 4.7.3 and 8.3 require the target to be a baseline manifest: a checkin with no B card of its own. ListFiles now returns an error otherwise. check-in: 9fbe647b7f user: [email protected] tags: main, trunk

23:39
fix(manifest): refuse check-in filenames outside the writer profile (#270) (#321)

Checkin wrote any F-card filename it was given. The artifact format's writer profile (4.6.1) forbids a backslash and anything but valid UTF-8, surrogates and overlong forms included; fossil rejects a manifest that names one. storeFileBlobs now refuses such a name or old name before storing anything. check-in: 357d10dac6 user: [email protected] tags: main, trunk

23:34
fix(delta): refuse a source or target of 2^32 bytes or more in Create (#279) (#320)

The delta format holds integers below 2^32 only, so a producer must not write a target length or copy offset at or past that. Create now returns ErrTooLarge for such inputs; Deltify then stores the content whole, as fossil does when a delta is not worthwhile, and the other callers propagate the error. Apply's header check becomes >= so it refuses exactly what Create will not write. The bound is tested on lengths, as a direct test needs a 4 GiB input. check-in: 38eddf43f1 user: [email protected] tags: main, trunk

23:28
fix(blob): fill a phantom in place when its content is stored (#278) (#319)

blob.Store, StoreDelta and StoreDeltaRaw now share writeRow, which fills a phantom row of the same name in place (content, size, and removal from the phantom table) as fossil's content_put does, and returns that rid. existingName no longer treats a phantom as stored content, so a check-in of a file matching a known phantom stores the file instead of recording a manifest that points at nothing. check-in: 23cfc00bfe user: [email protected] tags: main, trunk

23:18
fix(repo): resolve version names in fossil's order and forms (#290) (#318)

ResolveVersion chose a tag by tagxref.mtime instead of the carrying check-in's event.mtime, fell back to the tip when no check-in carried sym-trunk, tried a plain name before an artifact-hash prefix, and recognized none of fossil's rid:, tag:, root:, start:, merge-in: or NAME:TIMESTAMP forms nor the trailing-punctuation retry.

It is now a port of fossil's symbolic_name_to_rid (src/name.c), written as an ordered sequence of small attempts in fossil's order: tip (which, like fossil, falls through to the ordinary forms when there is no check-in), rid:, tag:, root:/start:/merge-in: (branch points, via start_of_branch and, for merge-in, the youngest ancestor in the branch), NAME:TIMESTAMP, then a 4-64 character hex prefix (tried before a plain tag, matching any artifact and reporting more than one match as ambiguous), then a plain sym- tag chosen by event.mtime, and finally a retry with one or two trailing punctuation characters removed. There is no tip fallback for an unknown name. The exported signature is unchanged; "" still means tip.

NAME:TIMESTAMP now rounds the boundary UP to the last millisecond of the stated precision (fossil's fossil_roundup_date / fossil_expand_datetime): a bare date becomes 23:59:59.999, HH:MM becomes :59.999, HH:MM:SS becomes .999. The compact YYYYMMDD forms, a fractional-second suffix and a trailing Z/UTC designator are accepted; the time is read as UTC, matching fossil's default timeline-utc. Bare YYYY-MM and hour-only forms are not supported. Unit tests cover the three round-up precisions (inside/outside the window), the zulu designator and the ambiguous-prefix path. check-in: 329ce1ad02 user: [email protected] tags: main, trunk

23:06
fix(branch): refuse an open branch's name on create; close a branch by its tip (#292) (#317)

branch.Create accepted a name an open branch already used, letting a second branch of the same name be created; and branch.List marked a branch closed when any check-in on it carried a closed tag, so closing a non-tip check-in hid an open branch.

Create now checks branch_is_open first (an open leaf whose active branch is the name) and refuses before building any artifact, so nothing is stored on refusal, matching `fossil branch new`. The check uses fossil's exact tag types: a propagating branch tag (tagtype=2) and a singleton closed tag (tagtype=1). List now derives a branch's closed state from its newest check-in's closed tag, the row fossil's branch-list query reads, so a branch with an open tip lists open. Unit tests cover the refusal, the no-stored-blob guarantee and reuse of a closed branch's name. check-in: faf47b6220 user: [email protected] tags: main, trunk

22:59
fix(checkout): count only open leaves for forks and fork warnings (#291) (#315)

BranchLeaves joined the leaf table without excluding leaves that carry a closed tag, so a closed leaf still counted toward a branch's leaves, and WouldFork, built on it, reported a fork for a version that was not itself a leaf. This made a closed leaf look like an open one and a non-leaf commit look like it would fork.

BranchLeaves now excludes any leaf with a closed tag (tagtype>0), matching fossil's leaf_is_closed_sql, so it returns a branch's open leaves. WouldFork is now fossil's leaf_ambiguity: the current version must itself be an open leaf (is_a_leaf AND not closed) before another open leaf on the same branch can make a commit fork; a non-leaf or closed version returns false. currentBranch now surfaces a real query error instead of masking it as "trunk". Unit tests cover the open-sibling, closed-sibling and non-leaf-parent cases. check-in: f8496d12cc user: [email protected] tags: main, trunk

20:59
test(conformance): sync suite follows the s7.3 uvgimme Read erratum (#313)

The new erratum to draft-fossil-sync-protocol-00 §7.3 has the server answer uvgimme only for a requester with Read, with Clone implying Read, and refuse silently otherwise. fossil 2.28 answers any uvgimme.

- The erratum is copied unmodified into conformance/rfcs/erratum and pinned in requirements/sync.tsv, with two new requirements, SP-7.3-11 and SP-7.3-12. - Server cases can carry a fossil note again: a case that expects something fossil(1) does not do says why, and fails once fossil(1) does it. sync.7.3.uvgimme-no-read uses it, so the case flags the day CI's pinned fossil takes the matching patch. - sync.7.3.uvgimme-clone-implies-read finds that go-libfossil's read gates ignore Clone; it is marked as a gap, tracked in #312. check-in: 2cb62c37bc user: [email protected] tags: main, trunk

13:57
test(conformance): sync-protocol conformance suite (#262) (#311)

* test(conformance): sync-protocol conformance suite (#262)

Checks go-libfossil against draft-fossil-sync-protocol-00, requirement by requirement: 149 requirements, 199 cases. Server cases send raw requests to Repo.XferHandler and, through `fossil http --in`, to fossil(1) on a copy of the same repository; client cases drive the public Sync and Clone against a scripted peer, with fossil(1)'s client run against the same script for most of them; HTTP cases cover redirects, status codes and media types; interop cases run whole exchanges in both directions.

Known failures are marked with the placeholder #0 until their issues are filed, so TestCoverage fails until then.

* test(conformance): sync suite review fixes (#262)

Cases for the requirements that had none where fossil(1) can show the behavior: server-version, the SHA3 gate, shun admission, cookie and cleartext logins, pushed configuration, ci-unlock, cluster brackets, the clone's unversioned catalog and the pull-only message. Two rows added: link ARG handling and the short uvigot.

A fossil(1) disagreement, a harness failure or a deviation other than the one named now fails a case whatever its mark. The server's log output is discarded in the tests.

* test(conformance): mark sync gaps with their issues; drop gaps v0.11.1 closed (#262)

v0.11.1's read gate and redirect policy make seven sync cases conform, so their gap marks are removed. The rest are marked with the issues that track each fix: #296 and #299-#310.

* test(conformance): accept either fossil refusal of a negative clone SEQNO

fossil 2.28's macOS build answers clone 3 -1 with clone_seqno 0 and its Linux build, which CI pins, with the invalid-sequence error alone. The draft leaves the reply open; the case now holds go-libfossil only to sending no content. check-in: 80c72b27c7 user: [email protected] tags: main, trunk

13:32
chore(release): v0.11.1 changelog (#298) leaf check-in: dbe626ea5d user: [email protected] tags: v0.11.1, trunk
13:28
fix(sync): send content only to readers; never follow HTTPS to HTTP (security) (#297)

* fix(sync): send content only to requesters allowed to read it

The sync server answered `gimme HASH` with the artifact whether or not a pull card had been accepted, and answered `pragma uv-hash`, `uvgimme` and `uvigot` with the unversioned catalog and files for any requester, anonymous ones included.

Match fossil's page_xfer:

- gimme is answered only when a pull card was accepted, or in a clone, which fossil also treats as pulling. - pragma uv-hash sends the catalog only to a requester with Read (o, i or Admin). uv-push-ok goes only to a requester with y; other readers get uv-pull-only, as fossil sends. - uvgimme and uvigot send nothing to a requester without Read, and uvigot adopts a client's mtime only for a requester with y. - Table sync follows the same rules: xgimme needs an accepted pull and pragma xtable-hash needs Read. - pragma ci-lock takes or reports a lock only for a requester with Write, so an anonymous requester cannot learn who holds one.

Pragma handling moves into handlePragma to keep handleControlCard short.

GHSA-mvx3-6793-8x4r

* docs(changelog): note that gating ci-lock on Write departs from fossil

GHSA-mvx3-6793-8x4r

* fix(sync): follow HTTP redirects as fossil does, never HTTPS to HTTP

Both HTTP transports used net/http's default redirect policy. It follows a redirect from HTTPS to plain HTTP, so a 307 or 308 resent the request, login card included, in cleartext. It also turns a 301 or 302 into a GET without the body and stops after 10 redirects, neither of which fossil does.

PostXfer now follows redirects the way fossil's http_exchange (src/http.c) and draft §4 describe. It replaces the client's redirect policy for these requests, then:

- refuses a redirect from HTTPS to HTTP, and to any scheme but http(s); - re-POSTs the same body and Content-Type for 301, 302, 307 and 308; - strips one trailing /xfer from the new location, as fossil's loop does (the draft says "repeatedly"; the code strips once); - fails on the twentieth redirect (MAX_REDIRECTS 20).

HTTPTransport and NewHTTPTransport both use it, including with a client passed through WithHTTPClient.

GHSA-3f8x-8fr5-mr98 check-in: 3a1014cf60 user: [email protected] tags: trunk

02:13
test(conformance): repository-state conformance suite (#262) (#295)

* test(conformance): repository-state conformance suite (#262)

Check go-libfossil against draft-fossil-repo-state-00, requirement by requirement. Each derived-state case is a scripted sequence of artifacts loaded into go-libfossil and into fossil(1); the tables derived from it are compared row by row, joined by artifact hash. Every case runs through manifest.Crosslink, as one sweep and one artifact at a time, and most also run as a ".rebuild" case through verify.Rebuild. fossil's rows must equal each expectation on every run, and each case states what ignoring its requirement would change, so a case cannot pass or fail by accident.

API cases cover name resolution, leaves, the ambiguity test, branch creation and listing, and tagging, each compared with fossil's answer on the same repository. TestRepoStateCorpus compares verify.Rebuild and a Crosslink sweep with fossil rebuild on real repositories named by CONFORMANCE_REPOS.

Known failures are marked with the placeholder Gap #0 until their issues are filed, so TestCoverage refuses them for now.

* test(conformance): repository-state suite review fixes (#262)

- Unsorted T-card cases use a non-hex name, so the ordering rule, not the hex-name rule, is what rejects them. - RS-4.2-7 crosslinks every artifact a second time over the rows the first left (repostate.4.2.relink-unchanged), instead of a sweep that links nothing. - RS-7.3-2, -3 and -4 are checked through manifest.Checkin and branch.Create, which maintain leaves one check-in at a time; a crosslink sweep recomputes the whole table. - retag-branch is built so the retagged check-in's own leaf status changes; branch membership is read through BranchLeaves, and the tagxref case it replaced is reattributed to RS-5.4-5; bad-prefix also compares tagxref. - parentless-untagged follows the draft and marks go-libfossil's crosslink, which follows fossil(1), as a deviation; the unused draft field becomes fossil, checked against fossil's rows. - Every fossil(1) command, in all suites, runs with HOME, FOSSIL_HOME and XDG_CONFIG_HOME in the test's directory, so it no longer records checkouts in the user's global configuration.

* test(conformance): mark repository-state gaps with their issues (#262)

Replace the placeholder gap marks with the issues that track each fix: #278 and #285-#294. check-in: 7fa0b9b5da user: [email protected] tags: trunk

01:38
chore(release): v0.11.0 changelog (#283) check-in: 59df292529 user: [email protected] tags: trunk, v0.11.0
01:34
fix: harden sync and repository defaults (security) (#282)

* fix(xfer): reject non-decimal card sizes and bound payload memory

A payload card's size was parsed with strconv.Atoi, so a peer sending `file HASH -1` reached readPayload's negative-size assertion and panicked the client or server decoding the message, and a signed size such as `+5` was accepted. Sizes now go through parseSize, which follows the sync draft's `decimal = 1*DIGIT` grammar and fossil's 32-bit limit and returns a decoding error for anything else. uvfile FLAGS use the same parser, so negative FLAGS are rejected as the draft requires. The assertion stays, now unreachable from peer input.

readPayload no longer allocates the declared size up front: its buffer grows with the bytes read, so a large size over a short message fails after allocating about what was sent.

A cfile payload may inflate only to its USIZE (a margin above it for a delta) and not past its 4-byte size prefix, and all cfile payloads in one message share a MaxCFileInflateBytes (1 GiB) budget, so a few megabytes of zlib cannot inflate to tens of gigabytes.

FuzzDecode exercises the decoder over both framings.

* fix(sync): refuse malformed xrow payloads and cap HTTP reply bodies

An xrow payload or xdelete PKData from a peer was decoded into a map and used as is. JSON null gave a nil map that reached coerceJSONNumbers' assertion, and a primary-key value of the wrong type or a nested value reached normalizeValue's, panicking the client or server. Both directions now decode through decodeRowJSON: one JSON object, nothing after it, and repo.ValidateRow's rules, which require scalar values and primary-key values that PKHash can normalize. A client refuses an xrow for a table it has not registered. Column types match case-insensitively, as table creation accepts them.

Both HTTP transports read replies through xfer.ReadBody, which stops at MaxBodyBytes (MaxDecompressedBytes plus framing headroom) and returns ErrBodyTooLarge, untagged for retry, instead of reading an endless body into memory.

FuzzTableSyncPayload drives the server handler and client over arbitrary payloads.

* fix(sync): check synced-table rows and identifiers before building SQL

repo.UpsertXRow wrote a row's map keys into its INSERT as column names, and the rows of an xrow payload or xdelete PKData come from the peer, so a key such as "val, mtime) VALUES(...) --" chose the statement's columns and values, and a key could select from other tables into a synced one. This held on the server for pushed rows and tombstones, and on the client for a server's rows.

A peer's row must now use exactly the table's declared columns: repo.ValidateRow refuses any other key, which also keeps a peer from setting _owner. UpsertXRow no longer trusts its caller: it reads the table's registered schema and refuses any key it does not declare, apart from _owner on an ownership-mode table. ListXRows, LookupXRowOwner and DeleteXRowByPKHash validate the table name and the column names they interpolate, so no synced-table identifier reaches SQL text unchecked.

* fix(sync): answer reqconfig only with fossil's transferable config

The sync server answered reqconfig NAME with any row of the config table, for any requester, including one with no capabilities, so project-code, server-code and every local setting could be read by anyone who could reach the sync endpoint.

handleReqConfig now follows fossil's page_xfer and configure.c. It answers only a /GROUP name, only for a requester with Read, matches the group by prefix in fossil's table order, and sends only the names fossil transfers in that group, in fossil's encoding. The user and subscriber tables also need Admin, which counts only for a verified login, and the concealed table needs RdAddr. A refused request gets no cards and no error.

The groups, their names and their capability rules live in one place, config_groups.go. A differential test answers the same requests with fossil's own server and with HandleSync on the same repository and requires identical config cards.

* test(sync): keep fossil http from leaving a backoffice process

* fix(sync): check the login nonce against the request body

The server verified a login card's signature over its nonce but never checked that the nonce was the SHA-1 of the request body after the login line, as fossil's check_tail_hash does (draft-fossil-sync-protocol-00 §6.2). A login card captured from one request therefore authenticated any other request body.

The decoder now records, for each login card, the SHA-1 of the decompressed bytes after the card's line, and VerifyLogin refuses a card whose nonce does not match it. A login card that was never decoded has no tail and is refused.

Login handling otherwise follows fossil's page_xfer and check_login: - a refused login ends the exchange with "error login failed" instead of continuing as nobody; - a second login card is refused with "multiple login cards"; - a successful login adds the user's capabilities to nobody's instead of replacing them; - nobody and anonymous log in without a password, and the reserved developer and reader logins and accounts with an empty password cannot be logged into.

Fixes GHSA-2g5p-q5f3-477p.

* fix(repo): seed nobody with fossil's default capabilities

A new repository seeded the nobody user with "cghijknorswy", so every request without a login card could push check-ins and unversioned files, write wiki pages and tickets, and held Setup. Seed the four built-in users `fossil new` creates, with fossil's capabilities: nobody "gjorz", anonymous "hz", developer "ei" and reader "kptw".

Setup and Admin now include check-in, read and clone, as they do in fossil, so the creating user can sync once given a password. A login to an account with no stored password is refused, as fossil's check_login refuses it; the creator starts with none, and the signature would otherwise be the SHA-1 of the nonce alone.

Tests that push without credentials grant nobody push explicitly. Existing repositories keep their stored capabilities.

* test(auth): reserved-login test updates the seeded developer and reader rows

New repositories now seed developer and reader as fossil does, so the test gives the existing rows a password instead of inserting them.

* docs(changelog): group the sync hardening fixes under Security

* test(sync): reqconfig tests sign their logins over the request body

The login nonce now has to cover the request body, so the reqconfig tests build their admin requests with signedRequest instead of a bare login card. check-in: 3a6126c3e9 user: [email protected] tags: trunk

01:15
fix(sync): a pull-only sync converges when the server lacks the client's artifacts (#277) (#281)

The server answered a pull-only client's igot cards with gimme for artifacts it lacked. The client was not pushing, so it never sent them and kept going round after round until it hit the round cap. Fossil's page_xfer handles igot only when the client sent a push card, and does the same here: a pull-only igot just records what the client has.

The client also treated the server's gimme cards as outstanding work even when it was not pushing, so any server that did ask (an older libfossil, or another implementation) could hold a pull open. A session without push now ignores gimme cards.

Two handler tests relied on a pull card alone producing a gimme; they now send a push card. check-in: 1e3e2df2f2 user: [email protected] tags: trunk

01:07
test(conformance): delta-format conformance suite (#262) (#280)

* test(conformance): delta-format conformance suite (#262)

Check internal/delta against draft-fossil-delta-format-00, requirement by requirement: 39 requirements, 88 cases. Decoder cases (Apply and OutputSize) are byte vectors, each also given to fossil(1) through its SQL delta functions; fossil's verdict must match unless the case records why it follows the draft instead (fossil masks the high bit, does not verify the checksum, and returns the size as a signed int). Each rejection breaks one rule and keeps the trailer valid, so check order cannot hide the rule under test. Producer cases check Create against a strict oracle and against fossil's delta_apply, and fossil's delta_create against Apply.

FuzzDeltaApply compares Apply with an independent model decoder; FuzzDeltaRoundTrip checks Apply(Create(s, t)) == t. DF-3.1-6 (Create has no 2^32 bound) has no case: it needs a 4 GiB input.

* test(conformance): delta suite names the reason each rejection must give

A rejection case could pass because some other rule rejected the input. Every rejection case now names a substring of go-libfossil's error (or ErrChecksum), and the harness fails the case when the message differs.

Vectors that were rejected for the wrong reason are fixed or reattributed: copy-truncated (DF-4-2), reject-10-nul (non-empty count, so the NUL is the opcode), copy-overflow-64 (DF-4-11), and high-bit-count, which now tells a masking decoder from a conforming one (fossil accepts it). The phantom-base case uses a literal-only delta; the content-discard case checks ErrChecksum and the store-side header refusals move to DF-4.1-3. The allocation bound gets its own row (DF-6-5) and takes the least of five runs. DF-3.1-6 notes issue #279. Drops a duplicate size-query case, the sizeCase helper and the decorative fuzz guard bytes. check-in: 0629de2beb user: [email protected] tags: trunk

00:42
test(conformance): artifact-format conformance suite (#263) (#276)

* test(conformance): artifact-format conformance suite (#263)

Adds the suite for draft-fossil-artifact-format-00 with errata 4.1.2, 4.2 and 4.6.4, so the parser, writer, naming, hash policy and delta manifest code are checked requirement by requirement instead of by scattered unit tests that each encode one reading of fossil.

- requirements/artifact.tsv: 101 requirements from Sections 3 to 8, pinned to the draft and the three errata. - Parse cases are a known-good base artifact per type plus one edit, with the Z card recomputed, so the edit alone explains a rejection. Every case also runs through fossil test-parse-manifest; fossil 2.28 agrees with all of them. Card legality, duplicates, arity and hash positions are generated from the bases and the draft's own tables. - Accepted inputs must re-encode byte for byte unless marked non-canonical. - API cases cover naming, verification, the comparator, the writer, the R card, the hash policy and delta-manifest enumeration. - FuzzArtifactRoundTrip checks parse/marshal/parse stability, and TestArtifactCorpus (CONFORMANCE_REPOS) parses every artifact fossil crosslinked in real repositories.

267 cases go-libfossil fails are marked with a "#0" placeholder in artifact_gaps_test.go, grouped by the code at fault, for issues to be filed against.

* test(conformance): tighten the artifact suite after review (#263)

A rejection only counts when the rule the case names makes it, so every rejection case that is not about the Z card must now pass deck.VerifyZ first. Inputs go-libfossil rejected only through the Z check (framing, residue, sterilizer, unstripped wrappers, an extra Z token) become gaps, along with VerifyZ itself, which rejects an absent Z card and reads the last byte where Section 6.2 says it must not.

- Cases can name the substring fossil's rejection reason must contain; the unknown-letter cases sit in sorted position, the sterilizer cases are filed under card order, which is what fossil rejects them for, and generated legality cases take their requirement from the draft's table. - The ratchet that never persists the hash policy is a gap: fossil opening the repository still reads auto. - New rows for 4.7.9, 6.2 (absent Z, last byte), 6.3.4 and 7.1. - The audit refuses the #0 placeholder, a test keeps the gap map and the fuzz test's forgiven losses tied to live gap cases, and one Z-card helper replaces two.

* test(conformance): mark artifact gaps with their issues (#263)

Replace the placeholder gap marks with the issues that track each fix (#265-#275), and keep coverage_test.go within 100 columns. check-in: d52a6fe050 user: [email protected] tags: trunk

2026-10-10
23:52
test(conformance): harness for an RFC conformance suite (#262) (#264)

Adds the conformance package that the per-draft suites build on. Nothing in the repository has yet checked go-libfossil against the Fossil drafts requirement by requirement.

- A Case names its requirement. A Gap (an issue) or Deviation (a reason) marks a known difference. A marked case still runs: while it fails it is skipped, and once it conforms it fails until the mark is removed, so the list of known failures cannot go stale. - Requirements tables pin the SHA-256 of the drafts and errata they were written against. Those are copied unmodified into conformance/rfcs by scripts/refresh-conformance-rfcs.sh. A changed draft fails the coverage test until its table has been re-read and its pin moved. - Coverage is derived from the cases rather than recorded: - COVERED: every case conforms. - PARTIAL: the table's row says what its cases leave out. - DEVIATION: a case is marked as a deliberate difference. - FAILING: a case is marked with an open gap. - GAP: no case yet.

CONFORMANCE_REPORT=<path> writes the requirement-by-requirement report as Markdown. - fossil(1) is run on every case's input, through test-parse-manifest, so a case's expectation is checked against fossil each time the suite runs. withZ appends the Z card, so a case that edits one card never also breaks the checksum.

The harness's own rules are tested in harness_self_test.go. No suites are registered yet; the artifact-format suite is next (#263). check-in: 57a558fcbf user: [email protected] tags: trunk

2026-10-09
15:35
Store check-in, link and tag rows as fossil does (#258) (#261)

* fix(manifest): store check-in, link and tag rows as fossil does (#258)

A rebuilt or locally written repository differed from fossil's in three derived values: event and plink times in their last digits, event.omtime left NULL, and a valueless tag stored as '' instead of NULL.

Fossil stores a tag time as SQLite's julianday() bound as a double, but writes event, plink, attachment and forumpost times into SQL text with its own printf("%.17g"), which emits 16 digits in long double arithmetic. TimeToJulian is now julianday() exactly (whole milliseconds over 86400000) and TimeToTextJulian ports fossil's printf conversion, so both match fossil bit for bit. The conversion follows builds where long double is a double (macOS, Windows); fossil itself differs across platforms in the last digit.

Check-in events record omtime, tag writes bind NULL for an empty value, the readers of event and plink times under the ncruces driver rebuild the text form, and the CLI's tag listing reads NULL values.

* test(db): sweep both stored julian forms; scan the text form from driver times (#258)

* test(verify): compare text times bit for bit only where fossil computes them in double (#258) check-in: 2c2e5e74b6 user: [email protected] tags: trunk

15:15
Clone and sync exchanges follow the sync draft (#255) (#260)

* fix(sync): clone and sync exchanges follow the sync draft (#255)

* docs(changelog): clone and sync exchanges follow the sync draft (#255)

* fix(sync): fail unfinished clones; ask for held phantoms once (#255 review) check-in: e46a6c3f60 user: [email protected] tags: trunk

14:47
fix(annotate): credit lines the way fossil annotate does (#256, #257) (#259)

Annotate aligned each version with its parent using its own LCS, so where a line repeats (blank lines, braces) it could pick a different copy as the new one than fossil does, crediting the line to another check-in. It also trimmed every trailing newline, dropping trailing blank lines.

Annotate now uses a port of fossil's line diff (break_into_lines, diff_all, longestCommonSequence and the rest of diff.c that annotation uses), hash included, since the hash orders the candidates and so decides ties. Like fossil's annotate, it diffs each older version against the starting one and credits a line to the version just newer than the first ancestor that lacks it. This also drops the table the size of both versions that the old LCS allocated at every step. check-in: ada7aadbb5 user: [email protected] tags: trunk

14:25
fix(annotate): credit lines through the start-version mapping (#253) (#254)

Past the first parent, walkParentChain wrote each line's attribution by its index in an older version, so lines were credited to the wrong check-in and an older version longer than the start panicked. Carry each compared line's start-version index through the walk and stop following lines that no longer match. check-in: a63adf1dcf user: [email protected] tags: trunk

2026-10-06
15:57
fix(checkout): merge the way fossil does, and commit merges with their parent (#244) (#252)

The CLI's merge marked a conflict with vfile.chnged=5, which fossil reads as "added by integrate", and recorded no merge-in version or vmerge row, so a merge went unseen and the next commit dropped the merge parent.

Merging now lives in the library as Checkout.Merge and records what fossil records: chnged=2 and mrid for a file taken from the merged-in version, chnged=3 for an added file, a per-file vmerge row for a merged file, and vmerge id 0 for the merge. Conflicts use fossil's markers and are found by looking for them (Checkout.Conflicts), so fossil and go-libfossil read each other's merges. Where fossil relies on its undo copy, Merge keeps the file or refuses instead.

Commit reads vmerge for merge parents, and, as fossil's commit does, refuses unresolved conflict markers, empty commits, unknown names and partial commits of a merge. The CLI's merge, conflicts, mark-resolved and ci commands call the library; ci now commits the checkout. check-in: 7ea6fba63f user: [email protected] tags: trunk

2026-10-03
19:00
ci(release): drop the downstream dispatch to EdgeSync (#251)

The release workflow's last step sent EdgeSync an upstream-bump event with a personal access token. It has failed with 401 on every release since v0.8.1 because the token expired, marking each release run failed after the release itself was published. EdgeSync also stopped accepting the event when the repository was renamed.

EdgeSync now tracks go-libfossil with Dependabot (danmestas/EdgeSync #199), which needs no token here, so the step is removed. A release run now succeeds once the GitHub release exists. check-in: af8e531e68 user: [email protected] tags: trunk

17:47
chore(release): v0.10.0 changelog (#250) check-in: e241048f77 user: [email protected] tags: trunk, v0.10.0
17:38
fix(checkout): revert undoes a pending rename (#242) (#249)

* fix(checkout): revert undoes a pending rename (#242)

Revert-all selected rows by content and removal, so a rename recorded by fossil mv (origname set, chnged 0) was skipped; a renamed and edited file had its committed content written under the new name, leaving the rename pending. A named revert only matched the current name.

Like fossil's revert, a pending rename is now undone: the file under the new name is removed, the row takes its old name back (UPDATE OR REPLACE, as fossil does), and the committed content is written there. Revert-all includes renamed rows, and a named revert answers to either the new or the old name. A renamed file a merge added is still removed outright, as before and as in fossil.

removeRegularFile is shared by the rename undo and removeMergeAdded, so neither deletes a directory the user made at that path.

* fix(checkout): plan a revert before changing anything (#242)

Undoing a rename with UPDATE OR REPLACE let a swap destroy files: reverting a.txt and b.txt exchanged through fossil mv deleted the other row, the loop then removed a file it had just restored from its stale row list, and the next restore failed half way, leaving both files gone from disk. A new file added under the old name was overwritten the same way. A named revert of the old name also stopped at the first match.

Revert now plans, checks, then acts. planRevert decides every target row's action (restore, un-manage, remove a merge-added file, undo a rename) before anything changes; checkRenameUndoFree refuses the whole revert when undoing a rename would land on a name another tracked row holds, since go-libfossil has no undo copy to recover an overwritten file (fossil saves one); revertFile carries out the planned action. The rename undo is a plain UPDATE, which fails rather than replace a row. A name selects every row it matches, new name or old. The callback reports a rename under the name the file gets back.

Swaps and chains, which fossil completes via its undo store, are #248.

* fix(checkout): revert named paths as one; refuse untracked file at old name (#242)

Review of the planned revert found two remaining ways to lose work:

- Several named paths were reverted one at a time, so in `revert b.txt z.txt` the edit to b.txt was discarded before z.txt's rename undo was refused. revertPaths now gathers every named row (new or old name) and plans and checks them as one revert: a refusal reverts none of them. - Undoing a rename restored the committed content over an untracked file at the old name. checkRenameUndoFree now also refuses when a file on disk there differs from the committed content (wouldClobber), for the same reason it refuses a tracked one: there is no undo copy.

* docs(changelog): note revert's untracked-file and multi-path rules (#242) check-in: 50d1a7f72b user: [email protected] tags: trunk

17:27
fix(content): report database faults from the availability check (#239) (#247)

* fix(content): report database faults from the availability check (#239)

IsAvailable and AvailableByUUID return a bool, so a busy, closed or I/O-failed database read as "content missing". Where missing content is tolerated on purpose, that swallowed the fault: since #231, CreateCheckout leaves vfile empty for a partly synced tip, and a fault during resolveFiles' availability check took the same path, so Create succeeded with an empty checkout.

The chain walk moves to checkAvailable, which returns an error for a fault and a plain false for an unknown rid, a phantom, a cycle or an over-long chain. New CheckAvailableByUUID exposes that distinction, and resolveFiles uses it, so a fault fails LoadVFile with its own error instead of errMissingContent. IsAvailable and AvailableByUUID keep their signatures and their fail-closed reading of a fault; their ~30 callers treat "not available" as a failure already.

* docs(changelog): note the availability fault fix (#239)

* fix(content): NULL size is missing content; don't cache faults (#239)

checkAvailable scanned blob.size into an int64, so a NULL size (legal in the schema) became a hard error in resolveFiles where it used to read as missing content. Both walks now read a NULL size as a phantom.

AvailabilityCache memoized a walk's verdict for every node it visited, including a false that came from a database fault, so one transient fault stayed wrong for the rest of the crosslink sweep. A faulted walk now returns false without being remembered; an unknown rid is still a verdict and is cached as before. check-in: 4b14509374 user: [email protected] tags: trunk

17:22
fix(checkout): dry runs change nothing; switching ends a merge (#236) (#246)

* fix(checkout): dry runs change nothing; switching ends a merge (#236)

A dry-run Extract still called LoadVFile(rid, clear=true) and finalizeExtract, replacing the current version's vfile rows (pending adds, renames, merge state) and moving vvar to the target. A dry-run Update still ran finalizeUpdate, with the same effect.

Extract now lists the target's files from its manifest (resolveFiles) instead of reading them back from vfile, so a dry run never loads them; only a real extract replaces vfile and records the version. Update skips finalizeUpdate on a dry run.

finalizeExtract also clears vmerge, as fossil's checkout does: a forced switch used to leave a merge record that would give the next commit a bogus merge parent.

* fix(checkout): forced extract starts fresh; re-extract keeps a merge (#236)

Two gaps against fossil's checkout found in review:

- A forced extract dropped only other versions' vfile rows (LoadVFile keeps the target's), so re-forcing the current version left a pending rename's two names on disk and in vfile, and a removed file restored but still marked deleted. fossil checkout --force deletes every row first; replaceFileList now does the same when Force is set. - Extract cleared vmerge on every completed run. fossil's checkout returns early when the version does not change and Force is off, keeping a pending merge; finalizeExtract now ends the merge only for a switch or a forced extract.

* refactor(checkout): finalizeExtract reads the prior version itself (#236)

Only the final step needs the version being replaced, and nothing changes vvar before it, so finalizeExtract reads it there. A dry run no longer calls Version at all, and Extract drops to 64 lines. check-in: 139d426384 user: [email protected] tags: trunk

17:09
fix(cli): add, rm, rename and revert call the library (#234) (#245)

* fix(cli): add, rm, rename and revert call the library (#234)

These commands wrote the checkout's vfile with raw SQL, each with its own copy of rules the library already gets right, and ignored most Exec errors. rename recorded the new name but left the file under the old one, so the next commit failed on a "missing" tracked file. revert only cleared change flags over an edited file and restored nothing. add wrote a SHA1 mhash whatever the repo's hash policy.

They now open the checkout through the library (openWorkingCheckout, which finds the repository from the checkout when -R is not given) and call Add, Remove, Rename and Revert, returning every error, including from closing. rename moves the file, like fossil mv --hard; revert restores committed content, like fossil revert.

Two library gaps the CLI exposed are fixed where they live, matching fossil's revert: - Revert refreshes change flags from disk before choosing files, so an edit no scan has recorded yet is reverted instead of skipped. - Reverting a never-committed add un-manages it and leaves the file on disk. It used to delete the file, destroying the only copy; fossil prints UNMANAGE and keeps it.

revertFile's restore path moves to restoreCommitted, bringing it under 70 lines. The merge, merge-resolve and conflicts commands are #244.

* docs(changelog): note the CLI and Revert changes (#234)

* fix(checkout,cli): revert and rename match fossil; find repo without -R (#234)

Review of the CLI switch found the library and the new helper falling short of fossil:

- Without -R, every command failed on a fresh fossil checkout: the repository lookup opened the checkout database read-only, which clashes with the WAL pragma on a database fossil left in delete journal mode, and hid the real error. It now opens it as the library does, reports the error, and resolves a relative repository path, as fossil open stores it, against the checkout's directory. - Rename moved the file onto an existing untracked file, destroying it. It now refuses, as fossil mv --hard does, before changing anything. - Revert skipped a file missing from disk (no change flag records it) while the CLI reported it reverted. A named file is now reverted whatever its flags say, and revert-all includes missing files. - Revert left merge state behind: restored rows kept mrid on the merged-in version and vmerge kept its row, so the next scan saw the file changed and the next commit recorded a merge parent. Restored rows now reset mrid and mhash, and revert-all clears vmerge, as fossil's revert does.

Rename's vfile lookups move to renameRowID and revert-all's selection to needsRevert, keeping both functions under 70 lines. The re-add test now checks the vfile row itself, since fossil's own scan would hide a stray chnged=1.

* fix(checkout): revert removes a file a merge added, as fossil does (#234)

Revert decided between restore and un-manage by rid alone. A file a merge added has a non-zero rid (its merged-in artifact) but is not in the checked-out version, so revert restored it as if committed and left it tracked; the next commit then included it. Fossil looks the file up in the version's manifest and, when it is absent and rid is not 0, deletes the file and its row (its content lives on in the merged-in version).

revertFile now takes the vfile row and the version's path set and does the same: a pending add is un-managed and kept on disk; a file the version lacks, under its name or its name before a pending rename, is removed; anything else gets its committed content back. Checking the pre-rename name keeps a renamed, edited file from being deleted.

Rename's refusal message now fits a directory at the destination too.

* fix(checkout): never remove a non-file when reverting a merge add (#234)

removeMergeAdded deletes only a regular file at the merge-added path; anything else there, such as a directory the user made, is left alone. Revert's doc now says edits to reverted files are discarded: fossil saves an undo copy first, go-libfossil has no undo yet. check-in: de8f3ed302 user: [email protected] tags: trunk

16:47
refactor(vfile): give the checkout's vfile encoding one owner (#233) (#243)

* refactor(vfile): give the checkout's vfile encoding one owner (#233)

Checkin, stash and status each decoded fossil's vfile columns on their own: rid=0 for an add, origname==pathname on unrenamed rows, chnged merge codes. That is how #228 happened, and stash still had it wrong: it selected chnged=1 only, so files changed by a merge (chnged 2..9) were silently left out, and it stashed a rename as a plain edit.

New package internal/vfile owns the encoding. Load reads a version's rows; Row answers what callers ask (IsAdded, IsRemoved, RenamedFrom, ContentChanged, PendingMerge); Classify gives the change type in fossil's order. checkout.FileChange is now an alias of vfile.Change, so the checkout API is unchanged.

internal/checkout's status, Extract safety check and checkin read rows through it. The never-read vfileCommitEntry.changed field is gone.

internal/stash selects changes with vfile.Classify against the checked- out version and refuses a pending merge or rename by name, since its save and revert paths restore files by current name against the committed version and would drop either. Its store-and-revert loop is split into stashRecord and revertStashedFile; removed and modified files share one revert path, keyed by row id.

* fix(vfile): a pending merge is chnged 2-5 or mrid != rid (#233)

PendingMerge counted every chnged above 1 as a merge, but 6 through 9 record a change to the executable or symlink bit; fossil leaves chnged=6 after a plain chmod +x. Stash then refused an ordinary permission change as "a pending merge". It also ignored mrid: an edit made on top of a merge drops chnged back to 1 while mrid still names the merged-in content, so the refusal could be bypassed and the merge half-dropped.

Row now carries mrid; PendingMerge is chnged 2..5 or mrid != rid, and ModeChanged covers 6..9. Stash refuses a permission change with that reason, since its revert writes default permissions.

Load reads a NULL rid as 0 and a NULL mrid as rid, the tolerance the checkin code had when it scanned rid into a NullInt64.

* refactor(checkout): ask vfile.Row about merges in Extract's check (#233)

Extract's safety check relaxed only rows with chnged == 1, decoding the column itself. It now asks the row (not a pending merge, not a mode change), keeping fossil's encoding in internal/vfile. Equivalent for every real row. nextChangedState's comment no longer calls the mode changes (6..9) merge states. check-in: f55d0ed2de user: [email protected] tags: trunk

16:28
fix(checkout): classify changes in one place; report missing files (#232) (#241)

* fix(checkout): classify changes in one place; report missing files (#232)

HasChanges, VisitChanges and Extract's safety check each decided what counts as a change: two SQL WHERE clauses and classifyChange, with an error path whose only job was catching their disagreement. They had already drifted once (HasChanges missed renames until #229).

Now one reader, loadVFileRows, returns every row of a version and one classifier, classifyChange, judges it, in the order fossil's changes command uses: removed > missing > added > renamed > modified. A renamed file that is also edited stays "renamed", so callers that see only the change type do not lose the rename. The WHERE clauses and the malformed-row error are gone.

VisitChanges with a scan now reports a tracked file that is gone from disk as ChangeMissing, which the public API already exposed as "missing" but nothing produced; such files were left out, or shown as added or modified. HasChanges sees a fossil add (rid=0, chnged=0) without a scan, since rid=0 now always means added (#231).

* fix(checkout): treat a non-file at a tracked path as missing (#232)

ScanChanges read each tracked path and failed the whole scan when a directory stood where a file belongs. Fossil's scan reports that path as NOT_A_FILE under its missing filter. The scan now stats first and treats anything that is not a regular file as missing, so Status reports it instead of erroring.

loadVFileRows orders rows by pathname, as fossil's changes lists them. The changelog notes that checkouts written before #231 may hold rid=0 placeholder rows, which HasChanges now counts. check-in: ff9c1af49d user: [email protected] tags: trunk

16:16
fix(checkout): refuse to load a version with missing content (#231) (#240)

* fix(checkout): refuse to load a version with missing content (#231)

LoadVFile wrote a vfile row with rid=0 for each file whose content the repo does not hold (a phantom, or a delta on one), as a partial sync leaves it. In fossil's vfile rid=0 means "added", so those rows were indistinguishable from pending adds, and Extract then panicked in content.Expand(0) trying to write them.

LoadVFile now does what fossil's checkout does: it resolves every file first and refuses the version, naming up to ten missing files and wrapping errMissingContent, before changing vfile at all. It no longer returns a missing count, which no caller used.

Create is the one caller that tolerates the refusal. A tip that a partial sync has not finished is no reason to fail CreateCheckout: the caller may be about to extract an older, complete version (EdgeSync's ExtractTo). vfile then stays empty instead of claiming files the repo cannot produce. Create's environment defaulting, shared with Open, moves to resolveEnv, and the initial load to loadInitialVFile.

Tests cover the refusal (vfile untouched, files named), Extract leaving the checkout as it was, Create on a partial tip followed by Extract of a complete version, and parity with the fossil binary, which refuses the same repository with "missing content".

* fix(checkout): refuse missing content in Update before writing (#231)

Update wrote changed files to disk and only then reloaded vfile, which now refuses a version with missing content. A target sharing a missing file with the current version (its content is never read, because it does not change) could therefore leave disk on the target while vvar and vfile stayed on the old version. Like fossil's update, Update now resolves the target's content before writing anything, through resolveFiles, the resolution step split out of LoadVFile.

Update moves its target selection into updateTarget, reuses Extract's checkinTime, and builds its result through sortedUpdateResult, bringing it under 70 lines. Create now reports a tip it could not load to the observer instead of tolerating it silently.

* fix(checkout): report Update refusals to observers; tighten checks (#231)

The missing-content check in Update moves after ExtractStarted, so a refused update still reaches observers as an ExtractCompleted carrying the error, as other Update failures do. A negative target from the public UpdateOpts is an error, not a panic. resolveFiles errors carry their own prefix instead of LoadVFile's.

Adds the entry and exit assertions the new helpers were missing, a compile-time check on missingNamesMax, and wraps lines over 100 columns; tests check Version's error instead of discarding it. check-in: a317896683 user: [email protected] tags: trunk

15:59
fix(blob): always inflate blob.content on load (#235) (#238)

* fix(blob): always inflate blob.content on load (#235)

blob.Load decided whether a row was stored raw by comparing len(content) with blob.size. That guess is unsound for delta rows: blob.size is the expanded artifact's length, while content is the compressed delta, and the two can be equal by coincidence. When they are, Load handed back the still-compressed bytes, and the delta applier failed with "expected integer at pos 0".

Go 1.27 replaced compress/flate's encoder, so the compressed size of any given delta shifted. The fixture in TestExpand_SingleDelta and TestIsAvailable_GroundedChain now compresses to exactly the target size (62 bytes), which exposed the bug. Any real repository could hit it under either Go version.

Fossil itself never makes this guess: content_of_blob (src/content.c:218-230) inflates blob.content unconditionally, and every writer here stores the compressed form. Load now does the same. TestLoadInflatesWhenStoredLengthEqualsSize forces the coincidence directly so it fails on the old code under any Go version.

Separately, TestDecodeRejectsOversizeDeclaredLength built its container from a 64-byte payload. Go 1.27's flate skips match-finding for inputs under 128 bytes, so that payload came out as a stored block larger than its input and tripped the helper's did-it-compress guard. The test now uses 1 KiB. The property it asserts is unchanged.

* docs(changelog): note the blob.Load inflate fix (#235) check-in: 60ef41b5b1 user: [email protected] tags: trunk

15:52
fix(checkout): check Extract's safety before replacing vfile (#230) (#237)

* fix(checkout): check Extract's safety before replacing vfile (#230)

Extract called LoadVFile(rid, clear=true) first and only then checked for local work. A refusal therefore returned with the current version's rows already gone: pending adds, renames and merge state were lost, and vvar still named the old version while vfile held only the new one, so Status saw an empty checkout. The check also compared disk against the target version, so switching a clean checkout to any version that changed a file was refused.

Extract now follows fossil's checkout command, before touching anything: the current version must have no unsaved changes, and no file the current version does not track may sit where the target would write different content (fossil prompts there; a library refuses). Force skips both, as before.

The safety scan runs through a new refreshChanged, the observer-free core of ScanChanges, so an Extract does not report a scan of its own. Extract is split into extractRows, checkinTime and extractRow to bring it under 70 lines; behavior of the write loop is unchanged.

* fix(checkout): let Extract proceed when no change would be lost (#230)

Create records the tip as checked out before any file is written, so Create followed by Extract of an older version, into a directory that already holds it, saw every file differing from the tip as an unsaved edit and refused. That is EdgeSync's ExtractTo pattern.

The safety check now refuses only changes the extract would lose: a plain edit whose content already equals what the target writes is not lost, so it no longer blocks. Adds, removals, renames, merge states and edits to files the target does not write still refuse. The edit check and the untracked-file check share wouldClobber, and the target manifest is read once and walked in manifest order.

* fix(checkout): never relax Extract's check while a merge is pending (#230)

A vmerge row is unsaved work on its own. With every merged file already matching the target, the previous commit let Extract switch versions, dropping the merge and leaving the stale vmerge row to give a later commit a bogus merge parent. The content-equality relaxation now applies only when no merge is pending. check-in: c9d97efb90 user: [email protected] tags: trunk

15:20
fix(checkout): read checkouts made by the fossil binary (#228) (#229)

Status on a checkout created by `fossil open` failed with "converting NULL to string" on vfile.mhash. go-libfossil treated mhash as every file's baseline hash; fossil only fills it while a merge is pending (the hash of mrid when mrid != rid) and judges a file against the blob that vfile.mrid names. A NULL-tolerant scan alone would not have fixed it: with no baseline the comparison panics, and skipping it would hide real edits.

ScanChanges now compares each file with blob.uuid for vfile.mrid and moves chnged through fossil's own transitions (vfile_check_signature): a pending add (mrid=0) becomes 1, unchanged and clean-merge states (0, 2, 4) become 1 on a difference, a reverted edit returns to 0, and other merge states are kept. Comparing against the artifact's own uuid also picks the hash algorithm per file, independent of hash-policy.

The non-forced Extract guard read mhash with its error discarded, so on a fossil checkout it skipped the check and overwrote local edits. It now uses the same baseline, propagates read errors, and refuses while a merge is pending (mrid != rid or chnged > 1).

Status and HasChanges treat a rename as origname not in ('', pathname), since fossil stores origname == pathname on unrenamed rows, and HasChanges now sees `fossil mv`. A malformed row returns an error instead of being reported as ChangeNone.

LoadVFile and Manage stop writing mhash, and Manage writes islink, which fossil's vfile schema declares without a default.

Tests drive real fossil-made checkouts: clean status, edits and adds, a pending merge (status, preserved merge state, extract refusal), local edits under extract, fossil mv, and a go-libfossil Add read back by both tools. check-in: 0069100efe user: [email protected] tags: trunk

2026-08-07
17:40
docs: backfill 0.8.0/0.8.1 and add the 0.9.0 changelog entry (#227)

The file jumped from Unreleased straight to 0.7.0 — neither 0.8.0 nor 0.8.1 was ever written up, and the hash-policy fixes had no entry. check-in: 4f17779bd3 user: [email protected] tags: trunk, v0.9.0

17:33
fix(db): seed hash-policy so new repos name artifacts like fossil init (#225) (#226)

SeedConfig never wrote hash-policy, so naming fell back to deriving from the repo's own artifacts — and an empty repo has none, settling on SHA1 permanently. Seed sha3, which is what fossil init writes.

Clone drops the row the same way it drops project-code: a clone adopts its source's policy, and an absent row derives naming from the artifacts that arrive.

Moves the policy enum to db so the seed and hash.NamingFor read one definition, and updates fixtures that hardcoded SHA1 names to ask the repo how it names artifacts. check-in: 1c02c944ec user: [email protected] tags: trunk

15:54
fix(blob): name new artifacts by the repo's hash-policy (#223) (#224)

Store hardcoded SHA1, so committing into a sha3 repo mixed hash families and made every tracked file read as CHANGED. Read hash-policy once per store and name new artifacts with the algorithm it selects.

Content already stored under the other algorithm's name keeps that name unless the policy forbids it (sha3-only, shun-sha1), so unchanged files don't churn their F-cards.

Also routes checkout.Manage and uv.Write through the same lookup, replacing their width-sniffing guesses — uv's read project-code, which is 40 hex chars in every repo, so it could only ever pick SHA1. check-in: 847c195286 user: [email protected] tags: trunk

2026-07-29
23:37
fix(manifest): preserve crosslink failure cause (#222) check-in: 2ed6f0207f user: [email protected] tags: trunk, v0.8.1
19:19
fix(test): reap fossil servers before temp-dir cleanup; raise unit-test timeout (#220)

Three tests started a real fossil server and killed it with Process.Kill() without waiting for the exit. Kill signals but does not reap, so the server could still be writing its -wal/-shm files while t.TempDir()'s cleanup walked the directory. A file appearing mid-walk fails RemoveAll with "directory not empty", which is how TestCloneFromRealFossilUncompressedReply failed on CI while passing locally -- it needs a loaded machine to lose the race.

internal/sync/integration_test.go already did this correctly, Kill followed by Wait, with a comment naming the same failure. This applies that established pattern at the three sites that lacked it.

Separately, the unit-test step ran with -timeout=120s, a per-package budget. CI runs the package binaries concurrently, so under contention internal/manifest and internal/sync exceeded it and panicked with "test timed out" despite finishing in 19s and 34s on an unloaded runner. Raised to 300s, matching the DST step, so the margin does not depend on how busy the runner is. check-in: 2256f5d7ce user: [email protected] tags: trunk

17:56
chore: untrack generated and local-only files (#219)

go.work.sum is only consulted for workspace-mode builds. CI never uses the workspace -- every step in test.yml runs GOWORK=off -- so the file earns nothing in the repository and re-churns on any local dependency change. Go regenerates it on demand for anyone building with the workspace active, so untracking it costs nothing and removes the churn.

docs/superpowers was added to .gitignore, but four files under it were already tracked; an ignore rule does not untrack what is already in the index, so the directory kept showing up in diffs regardless. Untrack it so the rule actually takes effect.

Both files stay on disk. Also terminates .gitignore with a newline. check-in: fd413ecc56 user: [email protected] tags: trunk

01:46
feat(cli): select sqlite driver by tag (#218) check-in: ac4c3537d1 user: [email protected] tags: trunk, v0.8.0
00:49
perf(sync): reuse received content in crosslink (#217)

Keep validated receive bytes in a bounded session linker so clone does not expand and hash every artifact again during finalization. Preserve bounded retry, cancellation, replay, and residual-repair behavior.

Closes #214 check-in: 7b6a67fb81 user: [email protected] tags: trunk

2026-07-28
22:08
fix(manifest): unify mlink derivation (#216)

Route direct check-ins and internal rebuilds through canonical mlink derivation. Update sparse-history consumers and preserve legacy conflict-fork rows with an explicit RID discriminator migration.\n\nCloses #213 check-in: 04f41a4c9c user: [email protected] tags: trunk

04:03
perf: stop clone re-seeking blob.uuid per F-card and over-allocating delta output (#187) (#212)

Cloning the Fossil SCM corpus (67.6k blobs) took 293-302 s at 1251-1287 MB against canonical fossil's 20.8 s at 205 MB. A CPU and allocation profile of one clone found the cost was not delta expansion or hash verification.

27.2% of all CPU was a single un-memoized statement. content.AvailabilityCache memoizes the delta-chain walk but not the "SELECT rid FROM blob WHERE uuid=?" that starts it, so manifest.missingCheckinRefs re-seeked the blob.uuid index once per F-card of every check-in -- the same file hash resolved again for every revision that did not change it. That index is far larger than SQLite's page cache, so each seek was three page reads: pread syscalls were 34.7% of total samples. The walk it guards was under 1% of its own cost.

Separately, materializing 8.27 GB of content allocated 360 GB. delta.ApplyInto starts at a 64 KiB cap deliberately -- the target length is a wire claim and a few bytes can declare 4 GiB -- and then climbs to the real size through append's ~1.25x large-slice growth, ~3.4x the target in discarded intermediates, leaving slack that content.Cache.clip copied away again (203.9 GB and 59.8 GB respectively).

Three changes:

- Memoize uuid -> rid in AvailabilityCache. Hits only. Within one crosslink run a hit cannot go stale, because a sweep and a cascade only ever add blob rows; a miss can, because ridOrPhantom and blob.StorePhantom create rows mid-run, and remembering an absence would leave an artifact deferred after the content it waits on arrived. The scope that makes this sound is now stated on the type, and TestAvailabilityCacheMustNotSpanAPurge pins its one boundary: shun.Purge deletes blob rows, so a cache must not span one.

- Size delta.ApplyInto's output exactly. scanOutputSize walks the command stream first without allocating, checking every copy against bytes the source holds and every insert against bytes the delta carries, so the size it returns is a measurement rather than the header's claim. Any stream it cannot walk to a clean terminator falls back to the existing growth path, so malformed and hostile deltas allocate and fail exactly as before. The buffer now arrives tightly sized, which also makes clip's copy a no-op.

- Cut crosslinkCacheBytes from 256 to 128 MiB. Each byte of that budget costs roughly five bytes of peak RSS -- GOGC doubles it into the heap goal, and the scavenger is paced off GC cycles, of which a faster sweep runs fewer. At 256 MiB the first two changes gave 205.7 s but 1473 MB, faster at the cost of memory. The measured curve (32/64/128/256 MiB: 369.6 s/318 MB, 292.3 s/447 MB, 249.4 s/812 MB, 205.7 s/1473 MB) puts 128 MiB where both axes improve.

Result on the same corpus: 245.4 s at 780 MB, from 293-302 s at 1251-1287 MB -- 17% less wall time and 39% less peak RSS, with instructions retired down from 3.37T to 2.48T. The gap to canonical narrows from 14.1x/6.3x to 11.8x/3.8x.

Derived tables are unchanged against an unmodified build cloning the same server: blob 67530, event 27099 (identical per type), mlink 104919, plink 23741, tagxref 51766, leaf 1426, filename 1496. The corpus parity gate passes.

What remains is structural rather than a missing optimization: a clone expands every blob twice, once in the receive-time phantom-fill cascade and once in the final whole-repository sweep, where canonical crosslinks each artifact from content it already holds in hand at receive time. Cache.Expand splits 45.4 s / 35.3 s across those two passes. Collapsing them is a change to the receive path and belongs in its own issue. check-in: 8b16c91f5c user: [email protected] tags: trunk

02:35
fix: derive mlink, plink, tagxref and leaf the way canonical fossil does (#193) (#211)

A full clone of the Fossil self-hosting repository disagreed with what canonical fossil derives from the SAME artifacts: mlink -3,351 rows, tagxref -632, plink -5, leaf -1. `fossil rebuild` on our own clone put every one of them back, so the artifact set was complete and the fault was entirely in crosslink-time derivation.

Six independent causes, each verified against `fossil rebuild` on a 67.6k-blob corpus clone:

mlink, -3,351. An mlink row records a parent-to-child TRANSITION, and a merge check-in has more than one parent. Canonical runs the same file-level diff once per parent -- primary, then each merge parent, then each cherrypick source -- and flags the extra rows isaux=1; we only ever diffed against the primary parent. add_mlink is now ported whole (internal/manifest/addmlink.go), which also brings the two passes the old code approximated: the delta-vs-delta revert/resurrect pass, and the baseline-child deletion pass with its mperm=0 delete rows. "Added by merge" (pid=-1) follows canonical's rule too -- a count over the rows a file produced, not a name lookup in the merge parents. A parent whose manifest cannot be read now yields NO rows rather than a whole tree of bogus "added" rows, and the rows are derived later from the parent's side once it arrives (linkPlinkChildren), so the result no longer depends on the order the sweep visits artifacts in.

tagxref, -612 of -632. A check-in's T-card does not have to name the check-in carrying it: `fossil merge --integrate` records the branch it closed as `T +closed <hash>` on the merge check-in itself. Canonical applies a check-in's, a control artifact's and a technote's T-cards through one code path; we applied only the self-referential ones on check-ins and none at all on technotes.

tagxref, the rest. A wiki-

or event-<id> tag's value is the W-card body measured after LEADING whitespace is skipped, not the raw card length.

Artifact identification. The sweep parses every blob in the repository, so what counts as an artifact decides which rows exist at all. Three gaps: a bare card letter with no arguments is legal (fossil's own first check-in spells its parentless-ness as a bare "P" card) and rejecting it dropped that check-in entirely; an N-card after a P-card is legal in a technote for historical compatibility, and rejecting it dropped real technote revisions with their event-<id> and bgcolor tags; and the check-in-versus-control decision turns on a comment, files or parents, which made `merge --integrate` check-ins read as control artifacts. Fossil's required-card table now gates linking (isArtifact), on both the sweep and the dephantomize cascade, so a file whose first line parses as a Z-card no longer links as a dateless check-in and a wiki revision missing its U-card no longer links at all.

plink, -5. The ancestry a check-in declares is a property of the check-in, not of what happens to be in the repository when it is crosslinked. A parent hash with no blob yet now reserves a phantom the way canonical's uuid_to_rid does, so the edge exists whether the parent arrives in a later round or never; skipping it lost the edge for good, because nothing revisits an already-linked artifact.

leaf, -1. Nothing calls leaf_rebuild() outside `fossil leaves --recompute`; the table is maintained by leaf_check() over the rids leaf_eventually_check() schedules -- each rid a branch tag lands on, plus that rid's parents. Drawing candidates from event instead both missed phantom parents (which fossil does list) and added check-ins that carry no branch tag and no ancestry (which it does not).

After: a fresh corpus clone and `fossil rebuild` of that clone agree row for row on mlink, plink, tagxref, leaf AND event -- 104,919 / 23,741 / 51,766 / 1,426 / 27,099 either way, and `fossil test-integrity` reports 0 errors.

Not addressed here: the local commit path (manifest.insertMlinks) still writes one mlink row per file rather than per change, and does not record merge-parent transitions. That is a separate divergence from the clone parity this issue is about.

Co-authored-by: Dan Mestas <[email protected]>

check-in: 5e6ef35173 user: [email protected] tags: trunk
01:50
perf: stop the post-sync crosslink sweep re-examining the whole repository (#202) (#210)

A pull into an already-up-to-date clone of the Fossil SCM repository took 2m44s and moved nothing. A CPU profile of that no-op pull puts 134.4s of 149.1s of samples (90%) inside manifest.CrosslinkContext, the whole-repository sweep Sync runs after the round loop. Nothing in the protocol path registers.

Two independent costs, both O(repository size) and neither bounded by what arrived:

collectCrosslinkCandidates 99.5s (67%) linkBatch -> Cache.Expand 34.9s (23%) of which hash.SHA3 26.0s (17%)

The query was quadratic. Its four exclusions were correlated NOT EXISTS subqueries, and tagxref is indexed on (rid, tagid) and (tagid, mtime) -- neither covers srcid -- so SQLite rescanned all 51,134 tagxref rows once per blob, 67,615 times. EXPLAIN QUERY PLAN showed the CORRELATED SCALAR SUBQUERY -> SCAN tx. Rewritten as NOT IN, each set materializes once and is probed: identical 39,727 rows, 47.7s -> 0.08s measured under canonical fossil's SQLite.

The candidate set never shrank. A blob qualifies when it has none of the derived rows a crosslink writes, and ordinary file content never gains those rows however often it is examined -- so 39,727 of the repository's 67,615 blobs were re-expanded (delta chain walked, SHA3 re-verified) and re-parsed on every sync. A probe run confirms the sweep linked exactly 0 artifacts on each of two consecutive runs over the converged repository, at 3m20s and 3m15s.

Canonical fossil has no equivalent cost because it never sweeps: xfer.c attempts one crosslink per blob as it arrives, artifact or not. This gives the sweep the same once-per-blob property via crosslink_nonartifact, an on-demand table recording blobs that expanded but hold no parseable artifact. Both facts are properties of immutable blob content, so the verdict cannot go stale; a blob that failed to expand, or that parsed but was deferred, is deliberately not recorded and is reconsidered next sweep.

Measured on a clone of the Fossil SCM repository (67,615 blobs), no-op pull:

before 2m43.9s after 0.40s

Clone is unchanged at 5m30s (it pays the one-time examination either way), and the two clones' derived tables are identical -- blob, event, mlink, plink, tagxref, filename and leaf totals all match row for row.

Fixes #202 check-in: 3748b95de7 user: [email protected] tags: trunk

00:44
fix: make authenticated sync interoperate with canonical fossil (#207)

* fix: make authenticated sync interoperate with canonical fossil (#203)

Every credentialed sync against a real fossil server was answered "login failed", whether or not the password was right. Push always needs a login, so push was unusable against any real server, as was pull from anything that did not grant anonymous read.

The login algorithm was never the problem. Two wire-level defects were, and each on its own produces that identical, password-independent rejection.

The push and pull cards carried their two codes in the wrong order. The canonical form is `push SERVERCODE PROJECTCODE` — canonical's own client writes `blob_appendf(&send, "push %s %s\n", zSCode, zPCode)` and both ends read the project code out of the second argument (src/xfer.c). We emitted and parsed project code first. A clone therefore adopted the server's server-code as its project code, and since a login signature is salted with the project code, every signature was derived from the wrong value. The same inversion put the server code where a server validates the project code, so an authenticated push was refused with "wrong project" even once the login worked.

The nonce covered a random comment that was never transmitted. The nonce is the hash of every byte following the login card, and the server re-hashes those bytes to check it (src/xfer.c check_tail_hash) — so the comment supplying the randomness has to be on the wire, not merely in the hash. We appended it to the hashed payload only, producing a nonce the server could not reproduce; it rejected the login before comparing the password at all, which is why a wrong password looked exactly like a right one. Comment lines now have a card type, so they can be sent as well as hashed.

Two smaller faults on the same path: a clone treated the server's round-0 "not authorized to clone" as fatal, ending the session before credentials were ever offered, where canonical tolerates that error on the first cycle precisely so the client can retry with the login card it can only build once it knows the project code; and the sync path signed with SyncOpts.ProjectCode rather than the code it had already resolved from the repository, so a caller that let the repo supply its project code — as the push and pull cards allow — panicked instead of authenticating.

The password derivation itself, sha1("<project-code>/<login>/<password>"), was correct and is unchanged. It is now pinned two ways: against the hash the fossil binary actually stores in user.pw, and against a login card captured from fossil 2.28 on the wire. Canonical's rule that an already hashed 40-hex password is used as-is is now honoured too.

Regression coverage stands up a canonical fossil server with anonymous capabilities removed, so a login card is the only way in, and asserts that a credentialed clone transfers content, a wrong password is refused, and a local commit pushed under authentication lands in the server's blob table and on its timeline. Nothing in the suite authenticated against real fossil before; clone was only ever exercised against servers granting anonymous read, where the login card is never sent, which is how this shipped.

Four xfer codec expectations that pinned the inverted argument order are corrected to the canonical bytes.

* fix(sync): correct the nonce doc to match what the server recomputes

The comment described the nonce as covering the encoded non-login cards and the signature as using the stored password hash. Both readings are wrong in ways that matter to anyone changing this code: the nonce is defined over the bytes that actually follow the login card on the wire, because the server recomputes it from what it received (src/xfer.c check_tail_hash), and the signature is salted with the project code rather than derived from user.pw directly. Hashing bytes the message does not carry produces a nonce the peer cannot reproduce, which is exactly the password-independent rejection this fix was chasing. check-in: a745f4f2ac user: [email protected] tags: trunk

00:27
refactor(tag): give the three tag-apply paths one propagation helper (#209)

AddTag, ApplyTag and ApplyTagWithTx each carried their own copy of the same type guard and eight-argument propagate call. #198 had to apply its mtime guard to all three, and a fourth site -- or one site updated while the others are missed -- reproduces exactly the shape that produced #197 (leaf derived two different ways) and #199 (project code resolved two different ways).

Extract propagateAfterInsert so the guard travels with the propagation instead of being restated at each caller, and cover the consolidated path. check-in: 4806de7907 user: [email protected] tags: trunk

00:18
fix: stop tag propagation from crossing branch boundaries (#198) (#206)

Branch tag propagation left 1,155 of 20,859 check-ins on a clone of the fossil repository carrying a different branch value than canonical, and tagxref at 68,883 rows against canonical's 51,764. Two independent defects in the port, both confirmed against fossil's own source and a canonical rebuild of the same blobs.

tag_insert is mtime-guarded and our tag application was not. Canonical opens it with

SELECT 1 FROM tagxref WHERE tagid=%d AND rid=%d AND mtime>=:mtime

and returns without touching tagxref -- and without calling tag_propagate -- when it matches (src/tag.c:173-186). AddTag, ApplyTag and ApplyTagWithTx all wrote unconditionally, so a check-in's own inline T-cards clobbered a later control artifact that had retagged the same check-in, then re-propagated the stale value to every descendant. On the real corpus one branch rename did this 19,115 times: check-in ab0a0d76 declares branch dual-license, three control artifacts later rename it to clear-title, and we reinstated dual-license and flooded the repository with it. The guard makes the outcome independent of the order the sweep visits the two artifacts in.

repairTagPropagation seeded from the wrong node. tag_propagate's per-child test is strict --

coalesce(srcid=0 AND tagxref.mtime<:mtime, 1) AS doit

-- so a descendant that already carries the tag holds it at exactly the origin's mtime, doit is 0, and the walk neither retags that child nor queues it. Replaying from tag origins therefore stopped dead at the first already-tagged descendant, stranding any check-in linked by a later sweep. Canonical seeds from the primary parent instead, right after inserting a new check-in's plink edges (src/manifest.c:2300-2302 and 2467-2469), which only ever tests that parent's own children. Seed from every primary parent, which is the sweep-order-independent equivalent. Worth 693 missing propagated branch rows.

Measured on a clone of the fossil repository against a canonical rebuild of the same blobs:

divergent branch values 1,155 -> 1 (of 20,858 compared) tagxref rows 68,883 -> 51,132 (canonical 51,764) leaf rows 1,463 -> 1,425 (canonical 1,426)

The residual 632-row tagxref gap is 613 missing `closed` singletons plus small change; it measures identically before and after this change and is a separate defect. The one remaining divergent branch value is a NULL-vs-empty-string difference in cancel-tag values, also pre-existing.

repairTagPropagation still runs before repairLeafTable, as #197 requires. check-in: 7043d6e3a9 user: [email protected] tags: trunk

2026-07-27
23:37
ci: gate the js/wasm build on every PR (#201) (#205)

test.yml had zero references to wasm or GOOS=js, so the WebAssembly build was never compiled on any leg. Browser support is the reason the ncruces driver is in the tree, and a break there would have shipped undetected -- including in db/config_wasm.go, whose js-tagged wasmPragmaOverrides() no job compiled at all.

Adds a build-only job covering 31 of the main module's 33 js-resolvable packages plus the ncruces driver submodule. The package set comes from go list rather than a hand-written list so new packages are gated by default. Two main packages are excluded, both for the same structural reason: cmd/libfossil and examples/embed-repo-api import the modernc driver directly in their own main.go, and modernc.org/libc has no js/wasm build. internal/stash and internal/undo declare //go:build !js in source and are dropped by go list itself. Building under -tags test_ncruces keeps internal/testdriver in the gate by pointing it at the driver that can actually target WASM.

Running tests under WASM needs a browser harness (wasmbrowsertest) and is deliberately out of scope; compilation catches the realistic failure mode. check-in: 10abca922c user: [email protected] tags: trunk

23:31
fix(sync): derive the project code, and write artifact visibility inside the storing transaction (#204)

* fix(sync): derive the project code from the repo instead of panicking (#199)

Supplying User/Password made SyncOpts.ProjectCode mandatory in practice: buildLoginCard passed the caller's raw value straight to computeLogin, which asserts it is non-empty, so an authenticated Sync on a repo whose project code the caller had not repeated took the process down. Nothing in the public API signalled that a password made the field required, and a server embedding the library died with it.

buildRequest already fell back to the repo's stored project-code for the push/pull cards; that fallback was local to one function, which is exactly how the login path drifted out of step with it. Resolve the code once when the session is built — caller value first, the repo's own config second — and let buildRequest and buildLoginCard both read it. A code that genuinely cannot be determined now returns a descriptive error from the site that needs it rather than panicking.

Sync's deferred result-not-nil assertion fired during panic unwinding, where err and result are both nil, replacing the original cause with its own message. The invariant only holds on a normal return, so it no longer asserts while a panic is in flight.

* fix(sync): write received artifacts' visibility inside the storing transaction (#200)

Receiving an artifact cost three write transactions, not one. The blob was stored in a transaction, its dephantomize cascade committed another, and then applyPrivateStatus re-derived the rid on the pool and issued a bare DELETE FROM private -- an implicit transaction of its own, with its own BEGIN IMMEDIATE and fsync, for a row that a transaction microseconds earlier had guaranteed did not exist. Every igot card announcing an artifact already held cost one more. Measured against a real fossil peer: 1509 statements executed outside a transaction to receive 1504 artifacts.

That is minutes of pure commit overhead on a large repository -- roughly 200,000 write transactions for a 67k-artifact pull -- and it is fragile as well as slow. Each of those writes contests the database's single write lock independently, so N artifacts means N chances to lose that contest and fail outright rather than one chance to wait at a single BEGIN.

Visibility now travels with the content it describes: storeReceivedFile takes the visibility a private card announced and records it inside the transaction that stores the blob, using the rid that transaction already has. The status is now atomic with the content rather than a separate write that could be interrupted between the two. The igot path buffers its updates and writes them once per round. The same three-transaction shape on the server's receive path goes with it.

The same real-fossil pull now executes 2 statements outside a transaction for those 1504 artifacts, down from 1509. db.DB.PoolExecs makes that budget assertable, which is how the regression test pins O(rounds) rather than O(artifacts) without needing a large corpus.

On the failure originally reported: the "database is locked" error came from a harness that copied a live SQLite database out from under a running server without its WAL, so that specific lock holder is not a product defect and this does not claim to fix it. The transaction storm above is a real performance and robustness defect on its own evidence, and is what this changes.

* test(sync): pin the transaction budget, not just writes outside a transaction

The budget tests measured PoolExecs, which counts statements issued outside a transaction. That pins one half of the defect and misses the other: moving a per-artifact write from the pool into its own WithTx leaves PoolExecs unchanged while every artifact still pays a BEGIN IMMEDIATE and an fsync, and still contests the single write lock on its own. That is the cost issue #200 actually measured -- roughly three write transactions per received file, about 203,000 for one clone-sized pull.

Verified by mutation: reverting the batched visibility flush to one transaction per update left both budget tests green. They now count transactions too, via a new db.DB.WriteTxns counter, and the same mutation fails with "began 51 write transactions for 50 igot cards over 1 rounds; want <= 4".

Also stop the contention test from starving the pull it is meant to observe. The rival connection re-acquired the write lock immediately on release, so on a loaded machine it could hold the pull's single BEGIN past busy_timeout and fail the test even when the batching under test is perfect -- which is what happened on CI. It now yields between bursts, modelling another process doing occasional writes rather than testing SQLite's fairness. check-in: 18bfbc166c user: [email protected] tags: trunk

22:04
fix: derive leaf by canonical's branch rule, in both the sweep and the commit path (#197)

* fix: derive leaf from fossil's branch rule and bound the deferral rollup

Leaf derivation dropped 66% of rows on the real Fossil repository (487 of 1,426) because both leaf paths treated any parent link as retiring a leaf. Canonical fossil retires a check-in only when a child carries the *same* branch name, with an absent branch tag reading as "trunk" (src/leaf.c: is_a_leaf / leaf_check / leaf_rebuild all share that predicate and none consult plink.isprim). Verified against fossil 2.28 itself: after a branch commit off the trunk tip, both the incremental table and `fossil rebuild` keep the trunk tip as a leaf even though its only child is its primary child, and a branch tip merged into trunk stays a leaf as well.

Two code paths were wrong, in the same way:

- repairLeafTable excluded every `pid` in plink, so merged branch tips disappeared -- the class that accounts for 939 of the corpus's leaves. - Checkin deleted every parent from leaf, so committing onto a new branch silently retired the branch point.

Both now share leafCheck/repairLeafTable in internal/manifest/leaf.go. The recompute also has to run after tag propagation rather than before it: a check-in that declares no branch of its own only has a branch value once propagation has written it, and comparing against a missing value made every inherited-branch tip look like a leaf.

Applying the new predicate to the real repository's own event/plink/tagxref yields 1,428 against fossil's 1,426, and every one of the six differing rows is fossil's incremental bookkeeping rather than its predicate: 2 are phantom rids fossil's leaf_check inserts as plink parents of check-ins it linked, and 4 are parentless check-ins with no branch tag, which nothing ever enqueues for a leaf check. Running fossil's own leaf_rebuild() on that repository gives 1,494, so it is leaf_check, not leaf_rebuild, that the shipped table reflects.

Also caps the deferral rollup's identifier lists (issue #194). A clone deferring 180 check-ins on 417 missing blobs rendered a single ~28 KB log line; the counts stay exact at INFO and the lists print eight entries plus an "...and N more" so nothing is dropped without saying so.

Fixes #189 Fixes #194

* chore: retrigger code scanning after branch restore check-in: df495ba69e user: [email protected] tags: trunk

20:03
fix(sync): retry a dropped exchange round instead of aborting the clone (#185) (#196)

A clone of a large repository is a conversation of dozens of rounds spanning minutes. A single transient disconnect anywhere in it aborted the whole operation with "http transport: Post ...: EOF" and produced no store at all: there was no retry, no reconnect, and minutes of transfer were discarded for one dropped packet. Reported failures landed at rounds 6, 11 and 18 across otherwise identical runs, which is the signature of independent random drops rather than a deterministic defect.

Both round loops now send a failed round again after a bounded exponential backoff — four attempts, 250 ms doubling to a 2 s cap — but only when the failure was the network dropping the exchange. The classification is an allow-list, not a deny-list: a reply that arrived whole and would not decode, a card the peer rejected, a 4xx, or an unrecognised error from a Transport this package does not know all still fail on their first occurrence. Retrying those would turn a reproducible defect into an intermittent one, which is worse than the failure retrying exists to prevent.

Telling the two apart cannot be done reliably after the fact — a truncated zlib body and a truncated wire read both surface as io.ErrUnexpectedEOF — so the transports tag their own wire stages via the optional TransportFault interface, in the same shape as FramedTransport. Non-2xx replies are now reported as such instead of being handed to the decoder, which also stops a peer's outage from being misreported as a framing defect.

The caller's context governs throughout: a cancellation or deadline aborts immediately, including during a backoff wait, so retrying cannot extend an operation past the bound its caller set (#166, #167, #183).

Resuming a partially-completed clone across process exit is deliberately not included: it needs the clone cursor persisted, a staleness policy for the server's snapshot bound, and a separate entry point, and within-session retry already preserves the expensive part. check-in: b2928e9629 user: [email protected] tags: trunk

19:57
fix: pin submodules to v0.7.0 instead of an orphaned pseudo-version (#190) (#195)

All three submodules required the root module at v0.6.4-0.20260724230440-48111c1c984d. That pseudo-version names a commit pushed on the #118 PR branch, which was squash-merged — rewriting the work as a new commit and orphaning 48111c1. `git branch -r --contains` finds it on no branch; it survives only in proxy.golang.org's cache.

CI never noticed because it resolves through the proxy. Direct-from-git resolution fails outright:

$ GOMODCACHE=$(mktemp -d) GOWORK=off go mod download github.com/danmestas/go-libfossil go: github.com/danmestas/[email protected]: invalid version: unknown revision 48111c1c984d

v0.7.0 is tagged and reachable, so the bootstrap constraint that forced a pseudo-version is gone. Repoint all three at the tag and tidy. The root go.mod hash is unchanged between the orphaned commit and v0.7.0, so this is a pure provenance fix with no dependency movement.

Add a release guard alongside the existing `replace` guard: any in-repo module required at a pseudo-version now fails the release before the tag ships. The pattern anchors on the 14-digit timestamp and 12-hex hash, and accepts either separator before the timestamp so the vX.Y.Z-0.<ts>-<hash> form is caught as well as v0.0.0-<ts>-<hash>. Third-party pseudo-versions such as ncruces/go-sqlite3-wasm are deliberately untouched. check-in: 9d35407ecb user: [email protected] tags: trunk

19:23
fix: crosslink ticket artifacts and stop warning for ordinary file blobs (#192)

* fix: write the ticket event row with its tag (#184), quiet the cascade (#186)

crosslinkTicket applied the tkt-<uuid> tag and returned the event row as a pending item for a second pass that was a no-op stub, so the row was never written at all. collectCrosslinkCandidates excludes any blob that already has a tagxref row naming its own rid as srcid, so the tag the incomplete operation wrote also hid the artifact from every later sweep. Cloning fossil's own repository produced 96 event rows of type 't' against canonical fossil's 3,808 -- 100% of the D,J,K,U,Z ticket-change shape lost, with nothing logged and test-integrity still clean, because only derived tables were wrong.

Implement the ticket rebuild instead of reordering the writes: there was no event write to reorder. ticketRebuildEntry replays every artifact carrying the ticket's tag in mtime order and derives its ticket, ticketchng and event rows, mirroring fossil's ticket_rebuild_entry, ticket_insert and manifest_ticket_event. It runs inline in the transaction that writes the tag, so the marker meaning "already linked" and the rows that make that true commit together or not at all. Replaying the whole tag set per artifact also makes the result independent of arrival order.

The event wording, the ticket/ticketchng column mapping including '+field' append semantics, the %h escaping and the two hyperlink hash lengths were all derived from the fossil binary rather than assumed, and are pinned by TestFossilBinaryTicketParity: a corpus built with `fossil ticket add`/`change` is crosslinked by us, rebuilt by fossil, and the two derivations compared row for row. TestCrosslink_TicketArtifactsProduceEventRows is the count assertion whose absence let this ship.

Audited the other three exclusion predicates for the same split-write shape: forumpost and attachment are each written by the same function, in the same transaction, as the event row that accompanies them, and propagate errors, so neither can diverge. No change made to them.

Secondary (#186): AfterDephantomize warned once per blob that failed to parse as a manifest, which is the ordinary outcome for file content and what the whole-repository sweep has always skipped silently -- ~39k lines on a fossil clone, burying the failures that matter. It now skips them the way linkBatch does. A blob that parses but fails to link is still a real fault and still warns.

* docs: record the ticket rebuild's measured cost and why it stays inline

The inline rebuild is quadratic in one ticket's artifact count. The cheaper deferred shape stays atomic but needs two call sites to remember to invoke it, and a forgotten call reintroduces #184 silently. Write the measurements and that reasoning down at the function, so a later profile of a repository with one hot ticket does not turn into an optimization back into the bug. check-in: 39f646e0ae user: [email protected] tags: trunk

19:19
test: add corpus parity gate comparing clone output against canonical fossil (#188) (#191)

The per-package parity tests compare our derivation against fossil's for fixtures built in-test, so they only cover the shapes someone thought to build. Issue #184 -- a silent 100% loss of the ticket-change artifact shape (D,J,K,U,Z) -- passed that suite throughout and was found by an external benchmark cloning the real Fossil repository.

Add TestCloneCorpusParity, which builds a repository with the canonical binary, serves it over loopback, clones it with both fossil and libfossil.Clone, and compares what each derived.

The comparison is per-event-type, not just totals. On the real Fossil repository the totals read 23,396 vs 27,099 -- a 14% shortfall that reads like rounding -- while the per-type breakdown put ticket events at 96 vs 3,808. Totals for blob, event, mlink, plink, tagxref and leaf are compared too, with deltas reported in both directions: our tagxref ran +17,119 over fossil's there, which is what writing a tag without finishing the work it marks looks like.

The corpus is built by the canonical binary at test time rather than downloaded -- seconds and under a megabyte, against the ~10 minutes and ~1 GB the real repository costs (#187) -- and covers check-ins with a branch and a merge, ticket creations and updates, a wiki page, a technote, an attachment, and a tag control artifact. requireCorpusShapes classifies every blob by its card set and fails if a shape falls below its floor, so a corpus that quietly stopped producing tickets cannot let the gate pass while covering nothing.

Gated behind LIBFOSSIL_CORPUS_PARITY=1 in addition to the existing fossil binary requirement, so the default suite is unchanged in runtime and outcome.

The gate fails on this commit, which is correct: #184 is unfixed, and it also surfaces a second divergence where a merged branch tip is missing from leaf.

Forum posts are not covered -- fossil exposes no CLI to create them. check-in: d01b94f4c1 user: [email protected] tags: trunk

17:41
fix: defer cascade-linked checkins referencing unavailable blobs (#180) (#183)

The whole-repository crosslink sweep holds back a checkin whose referenced blobs (F-cards or B-card baseline) have not arrived locally, via deferCheckin, so a later sweep can rediscover and complete it once the blob shows up. The mid-round phantom-fill cascade (afterDephantomize) had no equivalent guard and linked such a checkin unconditionally, writing its event row despite insertCheckinMlinks silently skipping the missing-blob F-card.

Because collectCrosslinkCandidates only reselects a rid with no event row, that half-linked checkin was permanently stranded: no future sweep, no matter how many sync rounds ran or when the missing blob eventually arrived, would ever revisit it. A clone that terminated before the blob arrived could leave the repository with a leaf checkin selected by CalcUpdateVersion whose Checkout.Update would fail with "blob not found" -- with no recovery path.

Factor the sweep's deferral bookkeeping (availability cache, deferred rids, missing blobs) into checkinDeferralGuard, shared by both linkState (the sweep) and cascadeLinker (the cascade). The cascade now checks shouldDefer before opening a checkin's savepoint, so a deferred checkin writes nothing to the batch -- matching the sweep's defer semantics exactly, and preserving the invariant both paths depend on: a deferred rid must carry no event row, or the sweep's own candidate query can never find it again.

deferCheckin's sweep-facing behavior, and linkBatch's call site, are unchanged -- it is now a thin wrapper over the shared guard. check-in: 36d61bd582 user: [email protected] tags: trunk

17:24
test(content): build the chain-bound fixture in one transaction (#182)

TestChainBoundBitesAtSameLength inserted its fixture a statement at a time: one blob row per chain node and one delta row per edge, across two arms sized maxDeltaChainDepth and maxDeltaChainDepth+1. At the current constant that is ~65k statements, each in its own implicit transaction and each paying a commit.

The chain is incidental setup; the subject of the test is the bound. But the setup cost dominated the runtime and was sensitive to how loaded the machine was, so on a contended runner it consumed the package's 120s budget and failed a PR that touched only CI YAML.

Wrap both loops in a single db.WithTx so the chain commits once. This follows buildBackwardChain in chain_depth_bench_test.go, which already builds its chain this way in the same package.

The assertions are untouched, byte for byte: the two walks must still agree at maxDeltaChainDepth and disagree one past it. Verified by mutation -- widening one walk's loop bound by a single node still fails the test with the same diagnostic it was written to produce.

Measured on this machine, three runs each, whole-package wall clock:

modernc before 19.7s / 25.0s / 7.1s after 1.19s / 1.36s / 0.80s ncruces before 3.5s / 10.0s / 7.5s after 1.27s / 1.59s / 1.13s

The spread collapsing matters more than the median: the old fixture's cost tracked machine load, which is what made it a timeout risk rather than merely slow.

Refs #179 check-in: 39f9ea4d8a user: [email protected] tags: trunk

16:19
docs: reconcile CHANGELOG with 15-tag release history (#181)

- Convert [Unreleased] to [0.7.0] and complete with remaining 67 commits - Add sections for v0.2.0 through v0.6.3 with commit-derived summaries - Add empty [Unreleased] section for future work - Highlight module path breaking change (libfossil → go-libfossil) - Maintain Keep a Changelog + SemVer format and existing prose style check-in: 35b79c90c6 user: [email protected] tags: trunk

14:22
perf: bound the delta encoder's hash-collision chain walk (#176) (#178)

emitMatches walked a hash bucket's entire collision chain at every target position with no limit. rollingHash is a weak multiply-accumulate over a 16-byte window, so low-entropy content — repeated tokens, runs of whitespace, ordinary SQL and C boilerplate — funnels an enormous number of source windows into one bucket, making Create quadratic in target length. Measured on the issue's repro (repeated 8-byte token, one 10-byte edit): 32 KiB 0.133s, 64 KiB 0.496s, 128 KiB 1.950s, 256 KiB 7.765s — ~3.9x per doubling — against 0.013s for a 128 KiB high-entropy control.

Bound the walk at maxChainCandidates, matching the `int limit = 250` that fossil's delta_create (src/delta.c) puts on the identical loop. The same walk also computed each candidate's match length twice, once to test it against the nHash threshold and again to read the value; compute it once.

Extract the walk into bestMatch so the bound is testable on a candidate count rather than on wall-clock time, which cannot flake on loaded CI.

Delta output changes, as fossil's own limit makes it change: a capped search can settle for a shorter match, so the encoder emits more copy commands. This lands our output on canonical fossil's — 128 KiB: 266 bytes vs fossil's 260; 1 MiB: 2142 vs 2120 — where before the unbounded walk produced 44 and 50 bytes and paid quadratic time for them.

After: 128 KiB 1.950s -> 0.018s, and 1 MiB 125.036s -> 0.062s.

Verified the deltas still apply byte-for-byte, by our Apply and by canonical fossil 2.28's test-delta-apply.

Co-authored-by: Dan Mestas <[email protected]> check-in: b1b6c09b41 user: [email protected] tags: trunk, v0.7.0