Artifact 1a15763c812f64e52d24bd7f80b2ad875ec9cc20ebd9ad1f6946766cd874743c:
- File
internal/zitcli/zitcli.go
— part of check-in
[7d2a820f65]
at
2026-08-23 19:17:55
on branch trunk
— landings: the decision surface
A list of what awaits a decision and a page per record: proposal, diff against target computed at view time, discussion, and the controls. accept rides `i` because it merges; everything else is a forum cap. Transitions render as events — zit stores them as replies, which is encoding the interface exists to hide. (user: dmestas, size: 38169) [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 // 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"` } // 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) { // 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). func (c *Client) TechnoteAdd(user, event, mimetype, comment string, body []byte) (id, hash string, err error) { args := []string{"technote", "add", "-R", c.repo, "--user", user} 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) { return c.ticketRun(append([]string{"landing"}, args...)) } // 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] }