// 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" + "# pathurletag 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 { root string recs map[string]Record } 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) { s := &Set{root: root, recs: map[string]Record{}} f, err := os.Open(path(root)) 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", path(root), n, len(parts)) } s.recs[parts[0]] = Record{Path: parts[0], URL: parts[1], ETag: parts[2]} } return s, sc.Err() } // 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 := path(s.root) 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) }