Artifact dcb3a2581c1864e797b1db2ad0d97beb5078394603b667f82e9b3c7cae273019:
- File
cli/repo_clone.go
— part of check-in
[a02c9572da]
at
2026-07-23 16:18:24
on branch trunk
— clone: add --timeout override and make the deadline reliably fire (#120) (#144)
The clone command hardcoded a 10-minute context deadline with no way to raise or disable it, and the deadline did not reliably interrupt a slow clone.
- RepoCloneCmd gains a --timeout flag (default 10m, matching the old behavior; 0 disables the deadline for very large repositories). cloneContext branches away from context.WithTimeout for a non-positive timeout -- WithTimeout(parent, 0) yields an already-expired deadline, which would abort instantly the very clone the flag exists to unbound.
- The whole-repository crosslink sweep now observes context cancellation. It runs after the exchange-round loop, so it had no round boundary of its own at which the deadline could fire -- on a large repository it could run long past the deadline uninterrupted. manifest.CrosslinkContext checks the context every 256 candidates; manifest.Crosslink stays a background-context wrapper so its ~30 existing callers are unchanged. processResponse gains the same batched check so a large single-round batch is interruptible too, and the sync session's and the serve handler's post-convergence sweeps are wired to their contexts for the same reason.
Each cancellation point is pinned by a test that fails without it: the crosslink and processResponse tests drive a context that survives the first poll and dies before the second, so only the batched check can observe it; the crosslink-sweep test cancels after a zero-card final round, so nothing before the sweep is left to notice.
Also adds a clone -> serve -> reclone fidelity regression guard (#114), built with the canonical fossil binary rather than this library's Commit. Commit does not deltify, so a corpus built through it holds zero delta rows and never reaches the serve side's deltified-row expansion branch -- a green result from such a corpus proves nothing about #114. The guard now asserts the source holds delta rows before measuring anything, and compares the second-generation clone's tip, artifact count, blob-store size, delta-row count and checked-out tree content against the source.
Measured against that corpus (71 artifacts, 54 delta rows), #114 does not reproduce: gen1 and gen2 both hold the same 54 delta rows, the same tip and artifact count, a 1.009 store ratio, identical checked-out content, and pass fossil test-integrity. The receive path re-deltifies what the serve side expanded, via content.Deltify on the crosslink mlink path, so the expansion the serve side performs does not survive into the store. (user: [email protected], size: 2269) [annotate] [blame] [check-ins using]
package cli import ( "context" "fmt" "time" libfossil "github.com/danmestas/go-libfossil" "github.com/danmestas/go-libfossil/internal/auth" ) // RepoCloneCmd clones a remote Fossil repository. type RepoCloneCmd struct { URL string `arg:"" optional:"" help:"Remote Fossil server URL"` Path string `arg:"" optional:"" help:"Local path for new repository file"` User string `short:"u" help:"Username for clone auth"` Pass string `short:"p" help:"Password for clone auth"` Invite string `help:"Invite token (from fossil invite)"` Timeout time.Duration `default:"10m" help:"Abort the clone if it runs longer than this; 0 disables the deadline"` } // cloneContext builds the context that bounds a clone. A positive timeout caps // the whole operation; zero (or negative) disables the deadline for a very // large repository, returning a plain cancellable context so the caller still // gets uniform cleanup through the returned cancel. func cloneContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { if timeout > 0 { return context.WithTimeout(parent, timeout) } return context.WithCancel(parent) } func (c *RepoCloneCmd) Run(g *Globals) error { if c.Invite != "" { token, err := auth.DecodeInviteToken(c.Invite) if err != nil { return fmt.Errorf("invalid invite token: %w", err) } if c.URL == "" { c.URL = token.URL } if c.User == "" { c.User = token.Login } if c.Pass == "" { c.Pass = token.Password } } if c.URL == "" { return fmt.Errorf("URL required (provide as argument or via --invite token)") } if c.Path == "" { return fmt.Errorf("path required") } ctx, cancel := cloneContext(context.Background(), c.Timeout) defer cancel() transport := libfossil.NewHTTPTransport(c.URL) opts := libfossil.CloneOpts{ User: c.User, Password: c.Pass, } r, result, err := libfossil.Clone(ctx, c.Path, transport, opts) if err != nil { return fmt.Errorf("clone failed: %w", err) } defer r.Close() fmt.Printf("Cloned into %s\n", c.Path) fmt.Printf(" Rounds: %d\n", result.Rounds) fmt.Printf(" Blobs: %d\n", result.BlobsRecvd) fmt.Printf(" Project-code: %s\n", result.ProjectCode) return nil }