package external import ( "fmt" "io" "os" "path/filepath" "git.hypertheory-labs.dev/loom/loom-cli/internal/lock" ) // CartDir is where a round happens. A cart is not committed — it lives in the // working tree of the machine the two presences share — so everything staged // here is deliberately outside version control. const CartDir = ".loom/cart/current" // PoladDir is where a changed external waits for somebody to decide. // // A polad is a candidate artifact, shaped exactly like what it would become, // staged so you can see whether it fits. Its exits are apply or discard, and // nothing may drift into being kept. const PoladDir = CartDir + "/polad" // cartOpen reports whether there is a round to stage into. The tool never opens // one: a cart is a bounded exchange between two presences, and starting it is // somebody's act, not a side effect of checking freshness. func cartOpen(root string) bool { fi, err := os.Stat(filepath.Join(root, CartDir)) return err == nil && fi.IsDir() } // stage writes a candidate copy into the cart, with the ETag that was served // alongside the bytes, so that applying it locks what somebody actually read. func stage(root, rel string, body []byte, rec lock.Record) error { dest := filepath.Join(root, filepath.FromSlash(PoladDir), filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return err } if err := os.WriteFile(dest, body, 0o644); err != nil { return err } locks, err := lock.LoadFile(poladLockPath(root)) if err != nil { return err } locks.Put(rec) return locks.Save() } func poladLockPath(root string) string { return filepath.Join(root, filepath.FromSlash(PoladDir), lock.File) } // usagesFor returns the path of the facet naming what depends on a document, and // whether it exists. // // Reconciliation runs the other way: the question is not what do we rewrite // here, but given what changed in theirs, what do we change in ours. The facets // usually survive unchanged — what moves is the manifests, the config, the code // that a usage named, which is why a usage names them. func usagesFor(root, rel string) (string, bool) { p := rel[:len(rel)-len(filepath.Ext(rel))] + ".usages.md" _, err := os.Stat(filepath.Join(root, lock.Dir, filepath.FromSlash(p))) return p, err == nil } // Apply moves a staged polad into place and moves its lock with it. // // This exists because the lock is the half a person forgets. Moving the file by // hand leaves a lock describing the copy you just replaced, which is the drift // the lock was there to prevent. func Apply(root string, rels []string, out io.Writer) error { staged, err := lock.LoadFile(poladLockPath(root)) if err != nil { return err } locks, err := lock.Load(root) if err != nil { return err } if len(rels) == 0 { for _, r := range staged.All() { rels = append(rels, r.Path) } } if len(rels) == 0 { fmt.Fprintln(out, "nothing staged") return nil } for _, rel := range rels { rec, ok := staged.Get(rel) if !ok { return fmt.Errorf("%s is not staged", rel) } src := filepath.Join(root, filepath.FromSlash(PoladDir), filepath.FromSlash(rel)) body, err := os.ReadFile(src) if err != nil { return err } dest := filepath.Join(root, lock.Dir, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return err } if err := os.WriteFile(dest, body, 0o644); err != nil { return err } locks.Put(rec) staged.Remove(rel) if err := os.Remove(src); err != nil { return err } fmt.Fprintf(out, "applied %s\n", rel) if u, ok := usagesFor(root, rel); ok { fmt.Fprintf(out, " check %s names what depends on this\n", u) } } if err := locks.Save(); err != nil { return err } return staged.Save() }