package gitea import ( "encoding/json" "os" "path/filepath" "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" ) // RemoteMapName is the ledger's file name, beside the issues it indexes. const RemoteMapName = ".remote.json" // RemoteMap is the number -> slug ledger: {"owner/repo#42": "wire-sqlc-appclick"}. // // ITS ENTRIES OUTLIVE THE FILES THEY NAME, and that is deliberate rather than a // leak. A push deletes an issue's file the moment the tracker confirms the // write, and the entry left behind is what makes the next pull of that number // land on the same slug — so every `depends:` that pointed at it still // resolves. Nothing prunes them, because "no file" no longer means "no such // issue"; eviction does not prune it either, for the same reason a push does // not. A stale entry costs one line of JSON and is corrected the next time that // number is pulled. // // It is a cache, not a record. The slug also travels tracker-side, in the issue // body, so losing this file costs a re-pull and not information — which is why // Load never fails and why a rebuild is a MERGE and never a replacement. The // order of authority: // // the tracker the issue, and the marker naming its slug // .remote.json a local number -> slug ledger, a cache of that marker // the store whatever happens to be checked out right now // // The store is a subset of what the ledger knows, so a rebuild that started // from the files alone would throw away every entry it cannot see. Start from // Load, add what the files say, Save. type RemoteMap map[string]string // RemoteMapPath is where the ledger lives: inside the issue store, beside the // issues. root is the STORE, not the payload scratchpad — this file is // bookkeeping about issues and belongs where they are. func RemoteMapPath(root string) string { return filepath.Join(root, RemoteMapName) } // LoadRemoteMap reads the ledger. // // A missing, unreadable or malformed file is an empty ledger and never an // error. The ledger is a cache of markers the tracker holds, so refusing to run // because it cannot be parsed would block the very pull that would rebuild it — // and the cost of starting empty is one re-pull, never a lost issue. func LoadRemoteMap(root string) RemoteMap { raw, err := os.ReadFile(RemoteMapPath(root)) if err != nil { return RemoteMap{} } var got RemoteMap if err := json.Unmarshal(raw, &got); err != nil || got == nil { return RemoteMap{} } return got } // Save writes the ledger, creating the directory if it is not there. // // The one write in this package allowed to create the store, and only because // of when it happens: the ledger is written the instant the tracker confirms a // push and BEFORE the local file is deleted, so failing it over a missing // directory would lose the slug at exactly the moment the local copy stops // being the record. // // Indented and key-sorted — encoding/json sorts map keys for us — because this // file is read by people and diffed by git as often as it is read by the // binary. func (m RemoteMap) Save(root string) error { if err := os.MkdirAll(root, 0o755); err != nil { return err } raw, err := json.MarshalIndent(m, "", " ") if err != nil { return err } return os.WriteFile(RemoteMapPath(root), append(raw, '\n'), 0o644) } // Slug is the local name recorded for a key, or "". func (m RemoteMap) Slug(k wire.Key) string { return m[k.String()] } // Set records that a key is known locally under this slug. func (m RemoteMap) Set(k wire.Key, slug string) { m[k.String()] = slug }