Restores something the specimen said and the round that discarded the specimen lost with it: a changed external becomes a polad in the cart, and somebody decides. check now stages what moved into .loom/cart/current/polad/ with the ETag that was served alongside the bytes, and prints the .usages.md beside it, because reconciliation runs the other way — the facets usually survive and what moves is the code a usage named. It says so when there is no usages file, which is its own finding. With no cart open, check reports and stages nothing. The tool does not open a round: a cart is a bounded exchange between two presences and starting one is somebody's act, not a side effect of asking about freshness. add now adopts what is not here and refuses what is already adopted, superseding the entry that had it announce an overwrite — it no longer overwrites at all. The one exception is the only way out of a dead end: a document present but unlocked was fetched by hand, nothing records its origin, and the path does not round-trip, so check cannot ask about it and a refusal would strand it forever. add accepts it and the bytes decide — identical locks it without rewriting anything, which makes the lock's assertion verified rather than assumed, and different stages a polad and leaves the local copy alone because it is the only evidence anything moved. apply exists because the lock is the half a person forgets: moving a polad by hand leaves a lock describing the copy you just replaced. Recorded with its limit — for an external, discard does not mean the change goes away, so discarding is really knowingly stale and nothing yet records that choice. Measured end to end on this repository: eight hand-fetched documents, all eight locked, nothing rewritten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
132 lines
3.9 KiB
Go
132 lines
3.9 KiB
Go
// Package lock reads and writes .loom/externals/.locks.
|
|
//
|
|
// One record per adopted document: where it was fetched from, resolved, and the
|
|
// ETag the publisher served with it. The ETag is opaque and is never a hash we
|
|
// compute — on gitea it happens to equal the git blob hash and on GitHub it does
|
|
// not, so a design that compares a local hash to a remote ETag works on exactly
|
|
// one host by coincidence. See .loom/event-log.md.
|
|
package lock
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Dir is where adopted documents live, relative to the repository root.
|
|
const Dir = ".loom/externals"
|
|
|
|
// File is the lock file, inside Dir.
|
|
const File = ".locks"
|
|
|
|
const header = "# loomctl locks — one record per adopted document.\n" +
|
|
"# path<TAB>url<TAB>etag The url is resolved: a short form would follow\n" +
|
|
"# whatever the default branch is at the time you ask.\n"
|
|
|
|
// Record is one adopted document.
|
|
type Record struct {
|
|
// Path is relative to Dir, and is for a person to read. The origin is the
|
|
// URL: the path does not round-trip, because it drops the route, the
|
|
// branch, and .loom/published/.
|
|
Path string
|
|
// URL is the resolved origin, branch and all.
|
|
URL string
|
|
// ETag is the publisher's, verbatim, including its quotes.
|
|
ETag string
|
|
}
|
|
|
|
// Set is every lock, keyed by path.
|
|
type Set struct {
|
|
file string
|
|
recs map[string]Record
|
|
}
|
|
|
|
// Path is the lock file for the repository at root.
|
|
func Path(root string) string { return filepath.Join(root, Dir, File) }
|
|
|
|
// Load reads the lock file for the repository at root. A missing file is an
|
|
// empty set, not an error: a repository whose externals were fetched by hand has
|
|
// no locks, and reporting that is the point.
|
|
func Load(root string) (*Set, error) { return LoadFile(Path(root)) }
|
|
|
|
// LoadFile reads a lock file from an explicit path. A staged polad carries its
|
|
// own alongside it, so that applying it uses the ETag that was served with the
|
|
// bytes somebody reviewed, rather than whatever the publisher serves later.
|
|
func LoadFile(file string) (*Set, error) {
|
|
s := &Set{file: file, recs: map[string]Record{}}
|
|
f, err := os.Open(file)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return s, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
sc := bufio.NewScanner(f)
|
|
for n := 1; sc.Scan(); n++ {
|
|
line := sc.Text()
|
|
if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
parts := strings.Split(line, "\t")
|
|
if len(parts) != 3 {
|
|
return nil, fmt.Errorf("%s:%d: want 3 tab-separated fields, got %d", file, n, len(parts))
|
|
}
|
|
s.recs[parts[0]] = Record{Path: parts[0], URL: parts[1], ETag: parts[2]}
|
|
}
|
|
return s, sc.Err()
|
|
}
|
|
|
|
// Remove drops a record.
|
|
func (s *Set) Remove(p string) { delete(s.recs, p) }
|
|
|
|
// Get returns the record for a path, and whether there was one.
|
|
func (s *Set) Get(p string) (Record, bool) { r, ok := s.recs[p]; return r, ok }
|
|
|
|
// Put adds or replaces a record.
|
|
func (s *Set) Put(r Record) { s.recs[r.Path] = r }
|
|
|
|
// All returns every record, ordered by path so the file diffs cleanly.
|
|
func (s *Set) All() []Record {
|
|
out := make([]Record, 0, len(s.recs))
|
|
for _, r := range s.recs {
|
|
out = append(out, r)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
|
return out
|
|
}
|
|
|
|
// Save writes the lock file, replacing it atomically so an interrupted write
|
|
// cannot leave a repository holding half a lock.
|
|
func (s *Set) Save() error {
|
|
p := s.file
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
return err
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(header)
|
|
for _, r := range s.All() {
|
|
fmt.Fprintf(&b, "%s\t%s\t%s\n", r.Path, r.URL, r.ETag)
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(p), ".locks-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tmp.WriteString(b.String()); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmp.Name())
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmp.Name())
|
|
return err
|
|
}
|
|
return os.Rename(tmp.Name(), p)
|
|
}
|