Artifact 07e093c7200a00e4853e76588e9171861183f061538f386f2cc85b407454122f:
- File
internal/zitcli/zitcli.go
— part of check-in
[bb05986ad8]
at
2026-08-24 09:41:16
on branch trunk
— landing page: probe the tip pair instead of scanning for the record
zit#134: landing show has to walk the whole store to FIND the record before it can answer about it, and quarry already folded that record out of its own index. zit merge --probe names the two check-ins, so the walk disappears -- 0.005s against 0.18s on this forge's own store, and the gap widens with store size, not with the number of landings.
Probing the same tips the cache is keyed on also closes a race: the key was built from resolved tips while the probe re-resolved a branch name, so a push landing between the two cached an answer under a key it did not describe.
An unresolvable tip now skips the probe instead of keying on ':' -- every landing whose branch could not be resolved shared that one cache entry and could be served another landing's verdict. (user: dmestas, size: 45702) [annotate] [blame] [check-ins using]
// Package zitcli is the subprocess boundary to zit (0001 §2): the zit // binary is the only store interface — never linked code, never direct // file reads. This package is a data plane; the arch gate restricts who // may import it. package zitcli import ( "bufio" "bytes" "context" "errors" "fmt" "io" "os" "os/exec" "sort" "strconv" "strings" "sync" "time" "craftdesign.group/quarry/internal/timing" ) // ErrCursorInvalid reports that the store was rewritten (pack, shun // reclaim) under a consumer's cursor. The remedy is a re-read from zero; // derived indexes rebuild, so the loss is detectable, never silent. var ErrCursorInvalid = errors.New("zitcli: cursor invalid: store was rewritten; re-read from zero") // Cursor is an opaque resume position from a prior Frames call. The zero // Cursor reads from the beginning (bootstrap). Only round-tripping the // values is supported — their content belongs to zit. type Cursor struct { Since string Verify string } // Artifact is one whole artifact as streamed by zit — delta storage is // resolved before emission, so Bytes hash to Hash. type Artifact struct { Hash string Bytes []byte } // Batch is one Frames result: the artifacts appended since the request // cursor, and the cursor to pass next time. type Batch struct { Artifacts []Artifact Cursor Cursor } // Client runs zit against one repository path (a checkout directory or // the .zit file itself, per zit -R). type Client struct { repo string // lsCache memoises file listings. A check-in's file set is IMMUTABLE // — the same version always yields the same listing — so a cached // entry can never go stale, which is what makes caching safe here // rather than merely convenient. // // It exists because `ls --long` costs roughly 4ms per file: it reads // each blob for size and the binary flag, so tcl's 2,198-path tip // takes 8.5 seconds. Fossil never pays that — its directory listing // comes from its own indexed tables and shows no size at all — but // quarry reads through the CLI by design (0001 §2), so the choice is // to compute once rather than to index separately. lsMu sync.Mutex lsCache map[string][]LsEntry // diffCache memoises check-in diffs under the same contract as // lsCache: a pair of immutable check-ins always diffs the same, so // an entry cannot go stale. It exists because the check-in page // costs TWO subprocesses (unified plus --brief) at ~75ms each even // when the unified half is then discarded for blowing the render // budget — and a reader walking a timeline revisits the same // check-ins constantly. diffMu sync.Mutex diffCache map[string]diffResult // mergeCache memoises the landing mergeability probe under the same // immutability contract, keyed on the PAIR OF TIPS rather than on // the landing: whether a branch merges is a function of the two // check-ins and nothing else, so two landings between the same tips // share an answer and a moved tip is a different key. // // It existed because the probe used to be a full-store scan: `landing // show` had to FIND the record before answering about it, which measured // 2.0s on this forge's own store and made the landing page the slowest // surface here by tenfold. zit#134 removed that cost — the probe now // names the two tips and skips the walk — so the cache is no longer // load-bearing for latency. // // It is kept because the merge computation itself is still real work // proportional to the changed file set, and because the key's contract // (a moved tip is a different key) is worth more than the microseconds // it saves: it is what stops a stale "merges clean" outliving the push // that falsified it. mergeMu sync.Mutex mergeCache map[string]mergeResult // blobs memoises file CONTENT, which is what the file page pays for: // zit cat costs ~147ms even for a small file, against a 3ms process // floor, because resolving a path at a version reads the manifest. // Byte-bounded because the values are arbitrary file contents. blobs *blobCache } func New(repo string) *Client { return &Client{ repo: repo, lsCache: map[string][]LsEntry{}, blobs: newBlobCache(blobCacheBytes), } } // blobCacheBytes is one repository's content budget. 64 MiB holds a // browsing session's worth of source files with room to spare, and // eleven repositories at that budget is a ceiling the process can // actually afford — the whole point of bounding by bytes is that the // worst case is a number someone chose rather than a number the corpus // chose. const blobCacheBytes = 64 << 20 // command builds a zit invocation bound to the request: cancelling the // request kills the child, and the call is attributed to the zit // category so the page can report what the store cost it. // // Cancellation matters more than it looks. Without it an abandoned // request leaves its subprocess running to completion — and `ls --long` // on tk runs for 2.6 seconds, so a reader who navigates away still costs // the machine the full amount. func (c *Client) command(ctx context.Context, args ...string) (*exec.Cmd, func()) { if ctx == nil { ctx = context.Background() } start := time.Now() return exec.CommandContext(ctx, "zit", args...), func() { timing.Record(ctx, timing.Zit, time.Since(start)) } } // lsCacheLimit bounds the memo. Listings are keyed by version, and a // browsing session touches few; the cap is only so a long-lived process // cannot accumulate one entry per check-in ever viewed. const lsCacheLimit = 64 // exit codes documented by `zit help frames`. const ( exitNoNew = 3 exitCursorInvalid = 4 ) // Frames reads every artifact appended after c into memory and returns // them together. // // PREFER FramesEach for anything that might face a real store: this // materialises the whole census, which is 13.2GB of stream for sqlite // and larger for tcl. It remains for tests and small fixtures, where // having the slice is simpler than a callback. func (c *Client) Frames(cur Cursor) (Batch, error) { var b Batch cursor, err := c.FramesEach(cur, func(a Artifact) error { b.Artifacts = append(b.Artifacts, a) return nil }) b.Cursor = cursor return b, err } // FramesEach calls fn for each artifact AS IT IS READ, returning the // cursor to pass next time. Nothing accumulates: peak memory is one // artifact plus whatever fn retains. // // The cursor arrives in a trailer after the last artifact, so it is // returned rather than yielded — a caller saves it once the stream ends. // If fn returns an error the read stops, the child is killed, and no // cursor is saved; the artifacts already handed over will simply be // seen again next round, which idempotent folds absorb. // // A current cursor yields nothing and returns the same cursor; a dead // cursor returns ErrCursorInvalid. func (c *Client) FramesEach(cur Cursor, fn func(Artifact) error) (Cursor, error) { return c.framesEach(cur, false, fn) } // FramesEachStructural is FramesEach with content blobs elided at the // source (zit#108): the census's bytes are mostly file contents the // index discards after the framing gate — 13.2GB vs 3.87GB on sqlite, // 6GB vs 682MB on tcl. Cursor semantics are identical. func (c *Client) FramesEachStructural(cur Cursor, fn func(Artifact) error) (Cursor, error) { return c.framesEach(cur, true, fn) } func (c *Client) framesEach(cur Cursor, structural bool, fn func(Artifact) error) (Cursor, error) { args := []string{"frames", "-R", c.repo} if structural { args = append(args, "--structural") } if cur.Since != "" { args = append(args, "--since="+cur.Since, "--verify="+cur.Verify) } cmd := exec.Command("zit", args...) var stderr bytes.Buffer cmd.Stderr = &stderr stdout, err := cmd.StdoutPipe() if err != nil { return Cursor{}, err } if err := cmd.Start(); err != nil { return Cursor{}, err } next, parseErr := parseFrames(bufio.NewReader(stdout), fn) if parseErr != nil { // Stop the child rather than draining a stream nobody will read; // on a 13GB census the difference is minutes. cmd.Process.Kill() } waitErr := cmd.Wait() switch code := exitCode(waitErr); { case code == exitCursorInvalid: return Cursor{}, ErrCursorInvalid case code == exitNoNew: // Trailer-only output; the parsed cursor is authoritative. case waitErr != nil && parseErr == nil: return Cursor{}, fmt.Errorf("zitcli: frames: %w: %s", waitErr, strings.TrimSpace(stderr.String())) } if parseErr != nil { return Cursor{}, fmt.Errorf("zitcli: frames: %w", parseErr) } return next, nil } // LsEntry is one file of a check-in as listed by `zit ls`. // // Size, Perm and Binary come from --long (zit#96). Before it existed a // listing could show none of them: size and binary cost a subprocess // per file, and the permission was unreachable at any price, because it // lives in the manifest rather than the blob. type LsEntry struct { Blob string `json:"blob"` Name string `json:"name"` Size int64 `json:"size"` // Perm is the raw F-card token: "-" for a regular file, "x" // executable, "l" symbolic link. Perm string `json:"perm"` Binary bool `json:"binary"` } // Executable reports the x permission. func (e LsEntry) Executable() bool { return strings.Contains(e.Perm, "x") } // Symlink reports the l permission. A symlink's blob content is its // TARGET path, so a viewer must not render it as file content. func (e LsEntry) Symlink() bool { return !e.Executable() && strings.Contains(e.Perm, "l") } // Ls lists the effective file set of a check-in (an on-demand read, // 0001 §2: ~ms subprocess cost, off the hot path, cache above here). func (c *Client) Ls(ctx context.Context, version string) ([]LsEntry, error) { return c.ls(ctx, version, "") } // LsUnder lists only the paths beneath a directory (zit#96's --under), // so drawing one directory does not materialise the whole tree — 2,198 // paths to show 146 of them, on a repository the size of tcl. func (c *Client) LsUnder(ctx context.Context, version, under string) ([]LsEntry, error) { return c.ls(ctx, version, under) } func (c *Client) ls(ctx context.Context, version, under string) ([]LsEntry, error) { // ONLY an immutable version may be cached. "trunk" names whatever is // newest, so its listing changes on every commit — memoising it // would serve a stale tree forever, which is a correctness bug // wearing a performance fix's clothes. if !immutableVersion(version) { return c.lsUncached(ctx, version, under) } key := version + "\x00" + under c.lsMu.Lock() if hit, ok := c.lsCache[key]; ok { c.lsMu.Unlock() return hit, nil } c.lsMu.Unlock() entries, err := c.lsUncached(ctx, version, under) if err != nil { return nil, err } c.lsMu.Lock() if len(c.lsCache) >= lsCacheLimit { // Whole-map reset rather than an LRU: the cache is an // optimisation, the cost of a miss is bounded, and an eviction // policy here would be more machinery than the problem needs. c.lsCache = map[string][]LsEntry{} } c.lsCache[key] = entries c.lsMu.Unlock() return entries, nil } func (c *Client) lsUncached(ctx context.Context, version, under string) ([]LsEntry, error) { args := []string{"ls", "--long", "-R", c.repo} if under != "" { args = append(args, "--under="+under) } args = append(args, version) cmd, done := c.command(ctx, args...) defer done() var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("zitcli: ls %s: %w: %s", version, err, strings.TrimSpace(stderr.String())) } var files []LsEntry for _, line := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") { if line == "" { continue } // --long: <blob> <size> <perm> <text|binary> <name>. The name is // last and may contain spaces, so the split is bounded rather // than greedy. fields := strings.SplitN(line, " ", 5) if len(fields) != 5 { return nil, fmt.Errorf("zitcli: ls: unparseable line %q", line) } size, err := strconv.ParseInt(fields[1], 10, 64) if err != nil { return nil, fmt.Errorf("zitcli: ls: bad size in %q", line) } files = append(files, LsEntry{ Blob: fields[0], Size: size, Perm: fields[2], Binary: fields[3] == "binary", Name: fields[4], }) } return files, nil } // ErrNoEntry reports a read of a file that is not available at the // requested check-in — absent from its file set, or content shunned. var ErrNoEntry = errors.New("file not available at this check-in") // Cat emits a file's exact bytes at a check-in (an on-demand read, // 0001 §2). The emitted bytes are hash-verified by zit itself. A file // zit cannot serve (untracked at that check-in, shunned content) comes // back wrapping ErrNoEntry. func (c *Client) Cat(ctx context.Context, path, version string) ([]byte, error) { // Only an immutable version is cacheable, for the same reason the // listing memo says so: a name resolves to different content over // time. Ops resolve names to hashes before calling (resolveVersion), // so this hits in practice rather than in theory. key := "" if immutableVersion(version) { key = version + "\x00" + path if hit, ok := c.blobs.get(key); ok { return hit, nil } if _, ok := c.blobs.get("miss\x00" + key); ok { return nil, fmt.Errorf("%w (%s@%.12s): cached absence", ErrNoEntry, path, version) } } cmd, done := c.command(ctx, "cat", "-R", c.repo, path, version) defer done() var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if exitCode(err) == 1 { // Absence is as immutable as content: a path missing at this // check-in is missing forever, and the About panel probes // .quarry/repo.json on every render — without this, each render // of a repository that has no config pays a subprocess to // relearn that, indefinitely. if key != "" { c.blobs.put("miss\x00"+key, nil) } return nil, fmt.Errorf("%w (%s@%.12s): %s", ErrNoEntry, path, version, strings.TrimSpace(stderr.String())) } if err != nil { return nil, fmt.Errorf("zitcli: cat %s@%s: %w: %s", path, version, err, strings.TrimSpace(stderr.String())) } if key != "" { c.blobs.put(key, out) } return out, nil } // InfoBranch is the branch NAME a symbolic reference resolves to — // `zit info trunk` on a repository that renamed its default answers // "main", because fossil's "trunk" means the default branch, not the // literal tag. zit is the authority on that resolution (0001 §2). func (c *Client) InfoBranch(ctx context.Context, ref string) (string, error) { _, branch, err := c.Info(ctx, ref) return branch, err } // Info resolves a reference to its check-in hash and branch name. // The first output line is the full hash; "branch:" names the branch. func (c *Client) Info(ctx context.Context, ref string) (hash, branch string, err error) { cmd, done := c.command(ctx, "info", ref, "-R", c.repo) defer done() out, cerr := cmd.Output() if cerr != nil { return "", "", fmt.Errorf("zitcli: info %s: %w", ref, cerr) } lines := strings.Split(string(out), "\n") if len(lines) > 0 { hash = strings.TrimSpace(lines[0]) } for _, line := range lines { if rest, ok := strings.CutPrefix(line, "branch:"); ok { branch = strings.TrimSpace(rest) } } if hash == "" || branch == "" { return "", "", fmt.Errorf("zitcli: info %s: unparsed output", ref) } return hash, branch, nil } // DiffEntry is one file in a name-status diff. type DiffEntry struct { Status string `json:"status"` // A, M, D Name string `json:"name"` } // diffResult is one memoised diff. type diffResult struct { unified string entries []DiffEntry } // diffCacheLimit bounds the memo. Diffs are larger than listings, so // this is smaller than lsCacheLimit: enough for a browsing session, // not enough to hold a repository. const diffCacheLimit = 32 // Diff compares two check-ins: unified hunks plus the name-status // summary (two zit invocations; both on-demand reads, 0001 §2). func (c *Client) Diff(ctx context.Context, from, to string) (string, []DiffEntry, error) { // Only an immutable PAIR may be cached, for the same reason the // listing memo says so. cacheable := immutableVersion(from) && immutableVersion(to) key := from + "\x00" + to if cacheable { c.diffMu.Lock() hit, ok := c.diffCache[key] c.diffMu.Unlock() if ok { return hit.unified, hit.entries, nil } } unified, entries, err := c.diffUncached(ctx, from, to) if err == nil && cacheable { c.diffMu.Lock() if c.diffCache == nil { c.diffCache = map[string]diffResult{} } if len(c.diffCache) >= diffCacheLimit { for k := range c.diffCache { delete(c.diffCache, k) break } } c.diffCache[key] = diffResult{unified, entries} c.diffMu.Unlock() } return unified, entries, err } func (c *Client) diffUncached(ctx context.Context, from, to string) (string, []DiffEntry, error) { // Two independent subprocesses over the same immutable pair; on a // repository the size of tcl each takes ~450ms, so running them // back-to-back doubles what the reader waits for a check-in page. var ( unified, brief string unifiedErr, briefErr error wg sync.WaitGroup ) wg.Add(1) go func() { defer wg.Done() brief, briefErr = c.diffOut(ctx, from, to, "--brief") }() unified, unifiedErr = c.diffOut(ctx, from, to) wg.Wait() if unifiedErr != nil { return "", nil, unifiedErr } if briefErr != nil { return "", nil, briefErr } entries, err := parseBrief(brief) return unified, entries, err } func parseBrief(brief string) ([]DiffEntry, error) { var entries []DiffEntry for _, line := range strings.Split(strings.TrimSuffix(brief, "\n"), "\n") { if line == "" { continue } status, name, found := strings.Cut(line, " ") if !found { return nil, fmt.Errorf("zitcli: diff --brief: unparseable line %q", line) } entries = append(entries, DiffEntry{Status: status, Name: name}) } return entries, nil } func (c *Client) diffOut(ctx context.Context, from, to string, extra ...string) (string, error) { // Two immutable hashes name one diff forever, so the answer is // memoisable under the same contract as the blob cache: it may make // the answer faster, never different. The diff page paid two // subprocesses (~30ms each) on every view of the same check-in. key := "" if immutableVersion(from) && immutableVersion(to) { key = "diff\x00" + from + "\x00" + to + "\x00" + strings.Join(extra, "\x00") if hit, ok := c.blobs.get(key); ok { return string(hit), nil } } // -R diffs from --from/--to work against a bare store now (zit#69), // so quarry never needs a checkout on disk. args := append([]string{"diff", "-R", c.repo, "--from", from, "--to", to}, extra...) cmd, done := c.command(ctx, args...) defer done() var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return "", fmt.Errorf("zitcli: diff %s..%s: %w: %s", from, to, err, strings.TrimSpace(stderr.String())) } if key != "" { c.blobs.put(key, out) } return string(out), nil } func exitCode(err error) int { var ee *exec.ExitError if errors.As(err, &ee) { return ee.ExitCode() } return 0 } // parseFrames reads the frames wire format: zero or more // "artifact HASH SIZE\n" + SIZE raw bytes + "\n", then "cursor SINCE TOKEN\n". func parseFrames(r *bufio.Reader, fn func(Artifact) error) (Cursor, error) { for { line, err := r.ReadString('\n') if err != nil { return Cursor{}, fmt.Errorf("reading frame header: %w", err) } fields := strings.Fields(line) switch { case len(fields) == 3 && fields[0] == "artifact": size, err := strconv.Atoi(fields[2]) if err != nil { return Cursor{}, fmt.Errorf("bad size in %q", strings.TrimSpace(line)) } raw := make([]byte, size) if _, err := io.ReadFull(r, raw); err != nil { return Cursor{}, fmt.Errorf("artifact %s: %w", fields[1], err) } if nl, err := r.ReadByte(); err != nil || nl != '\n' { return Cursor{}, fmt.Errorf("artifact %s: missing frame terminator", fields[1]) } if err := fn(Artifact{Hash: fields[1], Bytes: raw}); err != nil { return Cursor{}, err } case len(fields) == 3 && fields[0] == "cursor": return Cursor{Since: fields[1], Verify: fields[2]}, nil default: return Cursor{}, fmt.Errorf("unexpected frame line %q", strings.TrimSpace(line)) } } } // WikiCommit records a wiki artifact for title with body, returning the // new artifact hash (zit#71: a machine verb, hash-only stdout). func (c *Client) WikiCommit(title, user, mimetype string, body []byte) (string, error) { args := []string{"wiki", "commit", title, "-R", c.repo, "--user", user} if mimetype != "" { args = append(args, "--mimetype", mimetype) } cmd := exec.Command("zit", args...) cmd.Stdin = bytes.NewReader(body) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return "", fmt.Errorf("zitcli: wiki commit %q: %w: %s", title, err, strings.TrimSpace(stderr.String())) } return strings.TrimSpace(string(out)), nil } // TicketNew records a fresh ticket's first change and returns its // minted ticket id and the artifact hash. func (c *Client) TicketNew(user string, fields map[string]string) (id, hash string, err error) { out, err := c.ticketRun(append([]string{"ticket", "new", "-R", c.repo, "--user", user}, fieldArgs(fields)...)) if err != nil { return "", "", err } id, hash, found := strings.Cut(strings.TrimSpace(out), " ") if !found { return "", "", fmt.Errorf("zitcli: ticket new: unparseable result %q", out) } return id, hash, nil } // TicketSet records a change to an existing ticket, returning the // artifact hash. ErrNoEntry reports an unknown ticket id. func (c *Client) TicketSet(id, user string, fields map[string]string) (string, error) { out, err := c.ticketRun(append([]string{"ticket", "set", id, "-R", c.repo, "--user", user}, fieldArgs(fields)...)) if err != nil { return "", err } return strings.TrimSpace(out), nil } func (c *Client) ticketRun(args []string) (string, error) { cmd := exec.Command("zit", args...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if exitCode(err) == 1 { return "", fmt.Errorf("%w: %s", ErrNoEntry, strings.TrimSpace(stderr.String())) } if err != nil { return "", fmt.Errorf("zitcli: %s: %w: %s", strings.Join(args[:2], " "), err, strings.TrimSpace(stderr.String())) } return string(out), nil } func fieldArgs(fields map[string]string) []string { keys := make([]string, 0, len(fields)) for k := range fields { keys = append(keys, k) } sort.Strings(keys) args := make([]string, 0, len(keys)) for _, k := range keys { args = append(args, k+"="+fields[k]) } return args } // ForumPost opens a new forum thread (zit#76): title is the H card, // body the W card, mimetype the N card (zit#77); returns the root // post's artifact hash. An empty mimetype omits the card, which Fossil // reads as text/x-fossil-wiki — see markup's dispatch. func (c *Client) ForumPost(title, user, mimetype string, body []byte) (string, error) { args := []string{"forum", "post", "-R", c.repo, "--user", user} if title != "" { args = append(args, "--title", title) } if mimetype != "" { args = append(args, "--mimetype", mimetype) } return c.stdinHash(args, body) } // ForumReply replies to postHash within its thread (zit resolves the // thread root transitively); returns the reply's artifact hash. // User is one row of the store's user table (zit#124/#125): the // logins and capabilities a fossil repository carries, brought across // by `zit config import` or `zit config pull user`. type User struct { Login string Caps string PWHash string Info string } // Users reads the store's user table. `zit users --raw` prints one row // per line, TAB-separated: LOGIN CAPS PWHASH MTIME INFO. A store that // never imported a user table prints nothing, which is not an error — // it is a repository with no fossil people to carry. func (c *Client) Users(ctx context.Context) ([]User, error) { cmd, done := c.command(ctx, "users", "--raw", "-R", c.repo) defer done() var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("zitcli: users: %w: %s", err, strings.TrimSpace(stderr.String())) } var users []User for _, line := range strings.Split(string(out), "\n") { if strings.TrimSpace(line) == "" { continue } f := strings.Split(line, "\t") if len(f) < 2 { continue } u := User{Login: f[0], Caps: f[1]} if len(f) > 2 { u.PWHash = f[2] } if len(f) > 4 { u.Info = f[4] } users = append(users, u) } return users, nil } // ErrNoEntry reports an unknown or non-forum post. func (c *Client) ForumReply(postHash, user, mimetype string, body []byte) (string, error) { args := []string{"forum", "reply", postHash, "-R", c.repo, "--user", user} if mimetype != "" { args = append(args, "--mimetype", mimetype) } return c.stdinHash(args, body) } // TechnoteAdd records a technote (zit#76). event reuses an id to record // an edit (latest wins); empty mints a fresh one. Returns the event id // and the artifact hash. // // comment is the C card — the title a timeline renders for the note. // It can only be written here: zit documents that no later verb can // attach the card, so a note recorded without one shows its event id // forever (zit#77). // when is the E card — the moment the note is FILED under, which the // author chooses. Empty means now, which is right for a new note and // wrong for an edit: without it every edit re-files the note at the // moment it was edited, silently moving it in the timeline. func (c *Client) TechnoteAdd(user, event, mimetype, comment, when string, body []byte) (id, hash string, err error) { args := []string{"technote", "add", "-R", c.repo, "--user", user} if when != "" { args = append(args, "--time", when) } if mimetype != "" { args = append(args, "--mimetype", mimetype) } if comment != "" { args = append(args, "--comment", comment) } if event != "" { args = append(args, "--event", event) } out, err := c.stdinHash(args, body) if err != nil { return "", "", err } id, hash, found := strings.Cut(out, " ") if !found { return "", "", fmt.Errorf("zitcli: technote add: unparseable result %q", out) } return id, hash, nil } // stdinHash runs a machine verb with body on stdin, returning trimmed // stdout (the hash line). Exit 1 is a user error (unknown target, empty // body/author) and maps to ErrNoEntry. func (c *Client) stdinHash(args []string, body []byte) (string, error) { cmd := exec.Command("zit", args...) cmd.Stdin = bytes.NewReader(body) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if exitCode(err) == 1 { return "", fmt.Errorf("%w: %s", ErrNoEntry, strings.TrimSpace(stderr.String())) } if err != nil { return "", fmt.Errorf("zitcli: %s %s: %w: %s", args[0], args[1], err, strings.TrimSpace(stderr.String())) } return strings.TrimSpace(string(out)), nil } // ArtifactRaw returns one stored artifact's exact bytes, addressed by // hash. This is how attachment content reaches a reader: an attachment's // source is a plain blob, not a file within a check-in, so Cat (which // resolves a path at a version) cannot fetch it. // // max caps the response — an attachment is arbitrary user-supplied // content, and quarry buffers it to hand to the op layer, so an // unbounded read would be a memory denial of service. Exceeding the cap // is an error, never a truncated artifact: silently serving a prefix of // a binary is worse than refusing it. func (c *Client) ArtifactRaw(ctx context.Context, hash string, max int) ([]byte, error) { cmd, done := c.command(ctx, "artifact", hash, "--raw", "-R", c.repo) defer done() var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { if exitCode(err) == 1 { return nil, fmt.Errorf("zitcli: artifact %s: %w", hash, ErrNoEntry) } return nil, fmt.Errorf("zitcli: artifact %s: %w: %s", hash, err, strings.TrimSpace(stderr.String())) } if len(out) > max { return nil, fmt.Errorf("zitcli: artifact %s: %d bytes exceeds the %d-byte limit", hash, len(out), max) } return out, nil } // Pull fetches new artifacts from a peer into this store — the mirror // verb. It reports whether anything arrived, so a scheduler can stay // quiet on the overwhelmingly common no-op. // // This is the ONE call in this package that does not pass -R. `zit pull` // refuses the flag outright ("pull works in a checkout, and -R names a // repository with no working tree"), so the store is named by working // directory instead. That is fine for the stores quarry serves — they // carry a .zit-checkout — but it means a genuinely bare store cannot be // mirrored, which is a zit-side limitation, not a quarry choice. // // The URL is passed explicitly rather than relying on `zit remote`'s // saved value: quarry keeps mirror configuration in its own database, so // having a second, per-checkout copy of the same fact is a way for the // two to disagree about where a store mirrors from. func (c *Client) Pull(url string) (bool, error) { cmd := exec.Command("zit", "pull", url) cmd.Dir = c.repo var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return false, fmt.Errorf("zitcli: pull %s: %w: %s", url, err, strings.TrimSpace(stderr.String())) } changed, perr := pullChanged(out) if perr != nil { return false, fmt.Errorf("zitcli: pull %s: %w", url, perr) } return changed, nil } // pullChanged reads whether a pull brought anything in. // // It recognizes zit's two outcomes POSITIVELY and errors on anything // else, rather than defaulting to "nothing changed". The tempting // one-liner — `!contains("already up to date")` — inverts the risk: if // zit ever rewords the success line, a mirror keeps reporting no-change // forever, the index is never told to re-fold, and the store silently // stops tracking its upstream while the page shows it healthy. A loud // error on an unrecognized line is a bad afternoon; a silent one is a // stale mirror nobody notices. func pullChanged(out []byte) (bool, error) { s := strings.TrimSpace(string(out)) switch { case strings.Contains(s, "already up to date"): return false, nil case strings.Contains(s, "received"): return true, nil default: return false, fmt.Errorf("unrecognized pull result %q "+ "(zit's wording changed; teach pullChanged the new form)", s) } } // TagAdd attaches a raw-namespace tag with a value to a check-in // (zit#80). Raw is not optional for a verdict: without --raw the name // enters the symbolic namespace, where it resolves to a check-in like a // release name does — and the SECOND check-in to pass CI would make // "ci-pass" an ambiguous name for both. // // propagate carries the tag to descendants; a CI verdict does not // propagate, because passing says something about one check-in. func (c *Client) TagAdd(name, checkin, value string, propagate bool) error { args := []string{"tag", "add", "--raw"} if propagate { args = append(args, "--propagate") } args = append(args, name, checkin) if value != "" { args = append(args, value) } args = append(args, "-R", c.repo) cmd := exec.Command("zit", args...) var stderr bytes.Buffer cmd.Stderr = &stderr if _, err := cmd.Output(); err != nil { return fmt.Errorf("zitcli: tag add %s %s: %w: %s", name, checkin, err, strings.TrimSpace(stderr.String())) } return nil } // FileChange is one check-in that changed a file, from `zit log PATH`. type FileChange struct { Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` } // FileHistory returns the check-ins that changed path, newest first // (zit#93). A path that never changed yields nothing, which is not an // error — it is the honest answer for a file that only ever existed in // one state. func (c *Client) FileHistory(ctx context.Context, version, path string, limit int) ([]FileChange, error) { if limit <= 0 { limit = 50 } cmd, done := c.command(ctx, "log", "--long", "-n", strconv.Itoa(limit), "-R", c.repo, version, path) defer done() var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { if exitCode(err) == 1 { return nil, nil // no such path at this version: an empty history } return nil, fmt.Errorf("zitcli: log %s: %w: %s", path, err, strings.TrimSpace(stderr.String())) } var hist []FileChange for _, line := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") { rest, ok := strings.CutPrefix(line, "checkin ") if !ok { continue // continuation lines of a multi-line comment } // checkin <id> <timestamp> <user> <comment…> // A weekday token may precede the timestamp if zit#111's // annotation ever reaches this verb ambiently; tolerated here // as insurance, per the signoff on that issue. f := strings.SplitN(rest, " ", 5) if len(f) >= 2 && weekdayToken[f[1]] { f = append(f[:1], f[2:]...) } else if len(f) == 5 { f[3] = f[3] + " " + f[4] f = f[:4] } if len(f) < 3 { continue } fc := FileChange{Hash: f[0], Date: f[1], User: f[2]} if len(f) >= 4 { fc.Comment = f[3] } hist = append(hist, fc) } return hist, nil } // weekdayToken recognises zit#111's weekday annotation. var weekdayToken = map[string]bool{ "Sun": true, "Mon": true, "Tue": true, "Wed": true, "Thu": true, "Fri": true, "Sat": true, } // immutableVersion reports whether a version string names one fixed // check-in — a full artifact hash. Branch names, tags, "tip" and // timestamps all resolve differently as history grows, so a listing // taken under them may not be cached. func immutableVersion(v string) bool { if len(v) != 40 && len(v) != 64 { return false } for i := 0; i < len(v); i++ { c := v[i] if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return false } } return true } // ArchiveTo writes a check-in's files to dst as a reproducible archive // (zit#95). format is "tar" for gzip tar or "zip"; the two commands are // identical but for the container, so they share one implementation. // // No working tree is involved, so this works against a bare store, and // an F card's permission travels with each member — an executable // unpacks executable, a symlink stays a link, which is what a // checkout-then-tar workaround loses. // // max caps the archive quarry will produce. A repository whose tree // exceeds the cap is refused rather than half-served, and the partial // file is removed rather than left for a caller to mistake for one. func (c *Client) ArchiveTo(ctx context.Context, format, version, name, dst string, max int64) error { cmdName, ok := archiveCommand(format) if !ok { return fmt.Errorf("zitcli: unknown archive format %q", format) } args := []string{cmdName, "-R", c.repo} if name != "" { args = append(args, "--name", name) } args = append(args, version, dst) cmd, done := c.command(ctx, args...) defer done() var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("zitcli: %s %s: %w: %s", cmdName, version, err, strings.TrimSpace(stderr.String())) } fi, err := os.Stat(dst) if err != nil { return err } if fi.Size() > max { os.Remove(dst) return fmt.Errorf("zitcli: %s %s: %d bytes exceeds the %d-byte limit", cmdName, version, fi.Size(), max) } return nil } // archiveCommand maps a format to zit's subcommand. Only these two // exist; anything else is a caller's mistake and is refused by name // rather than defaulted, which would silently hand back the wrong // container. func archiveCommand(format string) (string, bool) { switch format { case "tar": return "tarball", true case "zip": return "zip", true } return "", false } // SeenSubmanifest reports whether this repository has ever held a // sub-manifest artifact. It is the fossil-compatibility question: an // S-bearing artifact is refused by peers below Fossil 2.29, and zit // documents that turning emission back off "cannot retire artifacts // already written" — so the setting is not the answer, the history is. // zit reports exactly that as "seen-submanifest". // // The command reads settings from the checkout rather than accepting // -R, so it runs with the repository as its working directory. About // 250ms on a large store, which is why callers cache it: the answer // changes only when an S-bearing artifact first arrives. func (c *Client) SeenSubmanifest(ctx context.Context) (bool, error) { cmd, done := commandIn(ctx, c.repo, "submanifest", "show") defer done() var out, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &out, &stderr if err := cmd.Run(); err != nil { return false, fmt.Errorf("zitcli: submanifest show: %w: %s", err, strings.TrimSpace(stderr.String())) } for _, line := range strings.Split(out.String(), "\n") { rest, ok := strings.CutPrefix(line, "seen-submanifest:") if !ok { continue } return strings.TrimSpace(rest) == "yes", nil } // A field that is not there is not a "no": a zit that stopped // reporting it would silently mark every repository compatible. return false, fmt.Errorf("zitcli: submanifest show: no seen-submanifest line") } // Leaves returns the open leaves of the check-in DAG, as zit defines // them: check-ins with no child on the same branch that carry no active // closed tag (repo-state RFC §7.2, §7.4). // // quarry asks rather than derives. Its own SQL answered "no child of // ANY kind", which is the phrasing §7.2 explicitly warns is not the // canonical test — on fossil that marked 490 check-ins as leaves where // the true set is 1432, and a different set, since no-child-at-all is a // strict subset of no-same-branch-child. Reproducing the rest (tag // propagation, blocking conditions, re-evaluation triggers) is several // more sections of the RFC, and landing near-but-not-equal is how a // forge shows false data about a store that is perfectly correct. // // It is cheap because zit's derivation cache already holds it: on tcl, // 103k artifacts, this is ~10ms warm against ~79s with --no-cache. func (c *Client) Leaves(ctx context.Context) (map[string]bool, error) { // -R names the repository. command() does not add it, so a call // without it silently answers about whatever store the process // happens to be standing in — which is the server's own checkout, // giving every repository quarry's leaves. cmd, done := c.command(ctx, "leaves", "-R", c.repo) defer done() var out, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &out, &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("zitcli: leaves: %w: %s", err, strings.TrimSpace(stderr.String())) } set := map[string]bool{} for _, line := range strings.Split(out.String(), "\n") { // "* <hash> <branch>" — the star marks the checkout's own leaf. f := strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), "* ")) if len(f) == 0 || len(f[0]) < 40 { continue } set[f[0]] = true } return set, nil } // LandingOpen proposes a branch to land on a target, returning the record's // id. The BODY ENCODING IS NOT OURS: a landing is a forum thread whose root // carries a marker and headers, and zit's landing verb owns that format // deliberately — // // THE INTERFACE HIDES THAT ENCODING ... If a thread hash leaks into the // interface, or accept starts asking callers to name a tag, this has // become a pass-through wrapper and the complexity it was built to // absorb has been pushed back up to quarry and to every human. // // So quarry mints landings by asking for a landing, never by writing a forum // post that looks like one. Two encoders of one format is how the forge and // the store come to disagree about what a record says. func (c *Client) LandingOpen(user, branch, target, message string) (string, error) { out, err := c.landingRun("open", "-R", c.repo, "--branch", branch, "--to", target, "--user", user, "-m", message) if err != nil { return "", err } return firstField(out), nil } // LandingSay appends to a record: a remark, or the transition that settles it. // verb is comment, reject or accept. // // accept is the one that can fail for a reason a reader must see rather than a // bug: it merges, and a merge can conflict. zit today also refuses accept // against a bare store (zit#131), which is the shape quarry serves — until // that lands, the error travels up and the page says so. func (c *Client) LandingSay(verb, id, user, message string) (string, error) { args := []string{verb, id, "-R", c.repo, "--user", user} if message != "" { args = append(args, "-m", message) } out, err := c.landingRun(args...) if err != nil { return "", err } return firstField(out), nil } func (c *Client) landingRun(args ...string) (string, error) { cmd := exec.Command("zit", append([]string{"landing"}, args...)...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() msg := strings.TrimSpace(stderr.String()) switch exitCode(err) { case 0: return string(out), nil case landingConflictExit: // zit#131: exit 5 means the merge would conflict and NOTHING was // written. That is a state to render, not a failure to report — // the difference this error exists to carry. return "", &LandingConflict{Paths: conflictPaths(msg), Message: msg} case 1: return "", fmt.Errorf("%w: %s", ErrNoEntry, msg) } return "", fmt.Errorf("zitcli: landing %s: %w: %s", args[0], err, msg) } // landingConflictExit is zit's status for "refused, conflicts" (zit#131). // Distinct from 1 on purpose: 1 is a user error worth reporting as a fault, // 5 is an ordinary state of a long-lived branch worth rendering as one. const landingConflictExit = 5 // LandingConflict is a refusal that changed nothing. It names the paths so a // page can say which files stand in the way rather than "it failed". type LandingConflict struct { Paths []string Message string } func (e *LandingConflict) Error() string { if len(e.Paths) == 1 { return "the branch conflicts with its target in " + e.Paths[0] } return fmt.Sprintf("the branch conflicts with its target in %d files", len(e.Paths)) } // conflictPaths reads the "conflict PATH" lines zit emits, on stdout for the // probe and on stderr for a refused accept. Anything else on the line is // prose for a human and deliberately ignored. func conflictPaths(text string) []string { var out []string for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "zit landing:")) if rest, ok := strings.CutPrefix(line, "conflict "); ok { if p := strings.TrimSpace(rest); p != "" { out = append(out, p) } } } return out } // mergeResult is one memoised probe answer. type mergeResult struct { state string conflicts []string } // mergeProbeRun invokes the read-only probe. Exit status is 0 whatever the // answer — zit#134 puts "has conflicts" on stdout as data, because it is an // answer and not a failure — so any non-zero here is a real error. func (c *Client) mergeProbeRun(branchTip, targetTip string) (string, error) { cmd := exec.Command("zit", "merge", "--probe", branchTip, "--into", targetTip, "-R", c.repo) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return "", fmt.Errorf("zitcli: merge --probe: %w: %s", err, strings.TrimSpace(stderr.String())) } return string(out), nil } // MergeProbe asks the STORE whether one check-in would merge into another, // without writing anything (zit#131). This is the only way a forge may learn // it: discovering mergeability by attempting an accept is the one thing a // server must not do, because the attempt is the damage. // // Returns "clean", "up-to-date", "unrelated", or "conflict" with the paths. // // It names the two TIPS rather than the landing (zit#134). `landing show` // answers the same question, but has to walk the whole store to FIND the // record first — a cost proportional to store bytes, paid to rediscover // something quarry already folded out of its own index. Naming the pair skips // that walk entirely. // // Passing tips rather than branch names also closes a race the old call had: // the cache key was built from resolved tips while the probe re-resolved the // branch, so a push landing between the two produced an answer cached under // the wrong key. Probing exactly what the key names cannot disagree with it. func (c *Client) MergeProbe(branchTip, targetTip, key string) (state string, conflicts []string, err error) { if key != "" { c.mergeMu.Lock() hit, ok := c.mergeCache[key] c.mergeMu.Unlock() if ok { return hit.state, hit.conflicts, nil } } out, err := c.mergeProbeRun(branchTip, targetTip) if err != nil { return "", nil, err } for _, line := range strings.Split(out, "\n") { if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "merges"); ok { state = strings.TrimSpace(rest) if i := strings.IndexByte(state, ' '); i >= 0 { state = state[:i] // "conflict (3)" -> "conflict" } } } conflicts = conflictPaths(out) if key != "" { c.mergeMu.Lock() if c.mergeCache == nil { c.mergeCache = map[string]mergeResult{} } c.mergeCache[key] = mergeResult{state: state, conflicts: conflicts} c.mergeMu.Unlock() } return state, conflicts, nil } // firstField takes the identifier off a machine verb's one result line. func firstField(out string) string { f := strings.Fields(strings.TrimSpace(out)) if len(f) == 0 { return "" } return f[0] }