Artifact 6097dae9dc2e4854ab05f71a213e836b33a7b4a89a4c0570a537525c38a6fac5:
- File
cli/repo_snapshot.go
— part of check-in
[e695dd75c7]
at
2026-10-19 23:02:27
on branch feat/cli-snapshot
— fix(cli): refuse a snapshot into a repository with no main-branch check-in
When the main branch resolved to nothing, snapshot took the repository for empty and committed a second root beside the existing history. It now treats only a repository with no check-ins as empty, and otherwise fails at exit 1 naming the branch. (user: [email protected], size: 5959) [annotate] [blame] [check-ins using]
package cli import ( "bytes" "errors" "fmt" "io" "os" libfossil "github.com/danmestas/go-libfossil" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" ) // snapshotAttempts bounds the retries when another process commits between // reading the trunk tip and writing on it. Each lost round means another // writer made progress, so a handful covers any realistic contention. const snapshotAttempts = 20 // errTrunkMoved is a snapshot's own commit finding the repository changed // since it read the trunk tip. var errTrunkMoved = errors.New("trunk moved since its tip was read") // RepoSnapshotCmd commits the bytes of one file onto trunk without a // checkout: the tip's other files carry forward, and bytes equal to the tip's // are a no-op that prints the existing tip. type RepoSnapshotCmd struct { Message string `short:"m" required:"" help:"Checkin comment"` Path string `required:"" help:"File name in the repository (relative, slash-separated); the bytes come from this path on disk unless --stdin"` Stdin bool `help:"Read the bytes from stdin instead of from --path on disk"` User string `help:"Checkin user (default: OS username)"` JSON bool `help:"Print {\"uuid\":...,\"changed\":...} instead of the bare uuid"` } // snapshotResult is the --json output of snapshot. type snapshotResult struct { UUID string `json:"uuid"` Changed bool `json:"changed"` } func (c *RepoSnapshotCmd) Run(g *Globals) (err error) { if err := validateRepoPath(c.Path); err != nil { return err } data, err := c.readContent() if err != nil { return err } user := c.User if user == "" { user = currentUser() } r, err := g.OpenRepo() if err != nil { return err } defer func() { if cerr := r.Close(); cerr != nil && err == nil { err = cerr } }() for range snapshotAttempts { res, err := snapshotOnce(r, c.Path, data, c.Message, user) if errors.Is(err, errTrunkMoved) { continue } if err != nil { return err } if c.JSON { return writeJSON(res) } fmt.Println(res.UUID) return nil } return fmt.Errorf("snapshot: trunk kept moving after %d attempts", snapshotAttempts) } func (c *RepoSnapshotCmd) readContent() ([]byte, error) { if c.Stdin { return io.ReadAll(os.Stdin) } return os.ReadFile(c.Path) } // snapshotOnce reads the trunk tip and commits name=data on it. The commit // re-checks, in its own write transaction, that no check-in arrived since the // read; otherwise it returns errTrunkMoved and the caller starts over, so a // commit never builds on a stale tip and drops another writer's files. func snapshotOnce(r *libfossil.Repo, name string, data []byte, msg, user string) (snapshotResult, error) { h := r.Inner() stamp, err := checkinStamp(r.DB()) if err != nil { return snapshotResult{}, err } parent, err := r.CurrentTrunk() if errors.Is(err, libfossil.ErrVersionNotFound) && stamp != 0 { // Check-ins exist but none on the main branch: a root here would // start a second, unrelated history. branch, berr := repo.MainBranch(h.DB()) if berr != nil { return snapshotResult{}, berr } return snapshotResult{}, fmt.Errorf( "snapshot: the repository has check-ins but none on its main branch %q", branch) } if errors.Is(err, libfossil.ErrVersionNotFound) { parent = 0 // an empty repository: this is the root check-in } else if err != nil { return snapshotResult{}, err } perm := "" if parent > 0 { cur, found, err := manifest.LookupFile(h, fsltype.FslID(parent), name) if err != nil { return snapshotResult{}, err } if found { perm = cur.Perm have, err := r.ReadFile(parent, name) if err != nil { return snapshotResult{}, err } if bytes.Equal(have, data) { uuid, err := checkinUUID(r, parent) return snapshotResult{UUID: uuid}, err } } } files, err := manifest.MergeParentFiles(h, fsltype.FslID(parent), []manifest.File{{Name: name, Content: data, Perm: perm}}) if err != nil { return snapshotResult{}, err } _, uuid, err := manifest.Checkin(h, manifest.CheckinOpts{ Files: files, Comment: msg, User: user, Parent: fsltype.FslID(parent), Precheck: func(q db.Querier) error { now, err := checkinStamp(q) if err != nil { return err } if now != stamp { return errTrunkMoved } return nil }, }) if err != nil { return snapshotResult{}, err } return snapshotResult{UUID: uuid, Changed: true}, nil } // checkinStamp identifies the repository's check-ins as of now: the newest // check-in's rid, which any new check-in raises. func checkinStamp(q db.Querier) (int64, error) { var stamp int64 err := q.QueryRow("SELECT coalesce(max(objid), 0) FROM event WHERE type='ci'").Scan(&stamp) return stamp, err } func checkinUUID(r *libfossil.Repo, rid int64) (string, error) { var uuid string err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", rid).Scan(&uuid) return uuid, err } // validateRepoPath rejects a name fossil would not accept as a new // repository file: one that is not a simple pathname in strict UTF-8 (file.c // file_is_simple_pathname: no leading, doubled or trailing slash, no "." or // ".." element, no backslash), one holding a control character, or one of the // names fossil reserves for its own files (file_is_reserved_name). func validateRepoPath(name string) error { if !deck.StrictPathname(name) { return fmt.Errorf("--path %q: want a clean relative path such as notes/x.md", name) } for i := 0; i < len(name); i++ { if name[i] < 0x20 { return fmt.Errorf("--path %q: a file name may not hold a control character (byte 0x%02x)", name, name[i]) } } if deck.IsReservedName(name) { return fmt.Errorf("--path %q: the name is reserved for fossil's own files", name) } return nil }