package issue import ( "os" "sort" ) // Closed issues leave the store. The store is a working set, not an archive. // // WHAT IS EVICTED, and it is two conditions, both read off the file: // // state: closed the work is done // origin: the work is somewhere else too // // THE SECOND CONDITION IS THE WHOLE SAFETY ARGUMENT. `origin: local` means this // file IS the issue — there is no other copy and deleting it deletes the work. // It is therefore never evicted, in any state, not even when named explicitly: // a closed local issue is reported and kept. The only files that go are ones // whose own metadata says the work can be fetched back, which is the same trade // a push makes when it drops a file the tracker has just confirmed. // // That parallel is exact except for where the confirmation comes from. Push has // to ask the tracker, because it is the tracker that just changed. Eviction asks // the file, because state and origin are domain fields and the answer is already // in the store — which is why this lives in the domain and needs no network, no // login, and no tracker. The sync layer's variant refreshes state from the // tracker first and then calls Evict, so there is exactly one implementation of // "what may be evicted" and it is this one. // // NOT A ONE-OFF MIGRATION. A pull by number fetches an issue in any state — a // number is an address, not a query — so a closed issue pulled after an eviction // lands on disk again. That is the tracker being asked a direct question, not a // regression; evict it again when you are done with it. // // `.remote.json` is deliberately NOT pruned. It is the local number -> slug // ledger, its entries outlive the files they name, and an evicted issue is in // exactly that state. INDEX.md is rebuilt, because it IS a view of the // directory. const closed = "closed" // LocalReason is printed whether or not the issue was named, because "this // closed thing is still here" needs an answer every time. const LocalReason = "origin: " + Local + " — this file IS the issue" // Evicted is one issue that left the store, with every file that went with it. type Evicted struct { ID string Paths []string } // Kept is one issue that was considered and stayed, with the reason. type Kept struct { ID string Why string Open bool // true when it is simply not closed yet — the normal case } // EvictReport is what a run did, or would have done. type EvictReport struct { Evicted []Evicted Kept []Kept DryRun bool IndexPath string IndexCount int } // Classify splits the store into what may be evicted, what is protected, and // what is still open. // // Pure — it reads the loaded issues and decides; nothing here touches disk. // ids restricts the question to those issues; empty considers the whole store. // A protected issue is returned as such even when it was named explicitly: // naming a file does not make deleting it safe. func Classify(issues map[string]*Issue, ids []string) (evict, protected, stillOpen []string) { chosen := ids if len(chosen) == 0 { for id := range issues { chosen = append(chosen, id) } sort.Strings(chosen) } for _, id := range chosen { i, ok := issues[id] if !ok { continue } switch { case i.State != closed: stillOpen = append(stillOpen, id) case i.IsLocal(): protected = append(protected, id) default: evict = append(evict, id) } } return evict, protected, stillOpen } // Remove deletes everything the store holds under one slug and returns the // paths that went. // // Deliberately dumb: it takes an id, not a decision. Whether an issue may go is // settled by Classify before this is reached, so the dangerous half of the // operation has no branches in it at all. func Remove(root, id string) ([]string, error) { var gone []string for _, p := range SlugFiles(root, id) { if err := os.Remove(p); err != nil { return gone, err } gone = append(gone, p) } return gone, nil } // Evict classifies, removes, and rebuilds the index. The one implementation, // called both by the offline command and by the sync layer — which does nothing // to this decision except hand over issues whose state it has just refreshed // from the tracker. func Evict(root string, issues map[string]*Issue, ids []string, dryRun bool) (*EvictReport, error) { evict, protected, stillOpen := Classify(issues, ids) rep := &EvictReport{DryRun: dryRun} for _, id := range evict { var paths []string if dryRun { paths = SlugFiles(root, id) } else { var err error if paths, err = Remove(root, id); err != nil { return rep, err } } rep.Evicted = append(rep.Evicted, Evicted{ID: id, Paths: paths}) } for _, id := range protected { rep.Kept = append(rep.Kept, Kept{ID: id, Why: LocalReason}) } for _, id := range stillOpen { rep.Kept = append(rep.Kept, Kept{ID: id, Why: "state: " + issues[id].State, Open: true}) } // Only when something actually went: the index is a view of the directory, // and rewriting it after a run that changed nothing is a write nobody asked // for. if !dryRun && len(rep.Evicted) > 0 { path, n, err := BuildIndex(root) if err != nil { return rep, err } rep.IndexPath, rep.IndexCount = path, n } return rep, nil }