package cmd import ( "flag" "fmt" "strings" "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" ) func init() { register(&Command{ Name: "evict", Group: GroupIssue, Args: "[…]", Short: "remove closed issues from the local store", Long: `The store is a working set, not an archive. What is evicted 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 never evicted, in any state, not even when named explicitly on the command line: a closed local issue is reported and kept. Eviction asks the file rather than the tracker, because state and origin are domain fields and the answer is already in the store — which is why this needs no network and no login. ` + "`kettle sync-evict`" + ` is the variant that refreshes state from the tracker first and then makes the same decision. Not a one-off migration: a pull by number fetches an issue in any state, so a closed issue pulled after an eviction lands on disk again. Evict it again when you are done with it. INDEX.md is rebuilt, because it IS a view of the directory. The number -> slug ledger is deliberately not pruned: its entries outlive the files they name, and that is what makes a pull land on the same slug afterwards.`, Examples: []Example{ {"kettle evict", "every closed issue that is not origin: local"}, {"kettle evict old-thing another-thing", "only these"}, {"kettle evict --dry-run", "print what would go; touch nothing"}, }, Setup: func(fs *flag.FlagSet) func([]string) error { dryRun := fs.Bool("dry-run", false, "print what would be removed; touch nothing") out := storeFlag(fs) return func(args []string) error { root, err := storeRoot(*out) if err != nil { return err } if !issue.StoreExists(root) { return Fail("store %s does not exist — nothing to evict", root) } issues, err := issue.LoadAll(root) if err != nil { return err } var missing []string for _, id := range args { if _, ok := issues[id]; !ok { missing = append(missing, id) } } if len(missing) > 0 { return Fail("no such issue(s) in the store: %s", strings.Join(missing, ", ")) } rep, err := issue.Evict(root, issues, args, *dryRun) if err != nil { return err } printEviction(rep, len(args) > 0) return nil } }, }) } func printEviction(rep *issue.EvictReport, named bool) { verb := "evicted" if rep.DryRun { verb = "would evict" } for _, e := range rep.Evicted { fmt.Printf("%-11s %s\n", verb, e.ID) for _, p := range e.Paths { fmt.Printf(" %s\n", p) } } for _, k := range rep.Kept { // An open issue is the normal case and says nothing worth a line — // unless the operator named it, in which case they are owed the reason. if k.Open && !named { continue } if k.Open { fmt.Printf("%-11s %s %s\n", "kept", k.ID, k.Why) } else { fmt.Printf("%-11s %s closed, %s\n", "kept", k.ID, k.Why) } } if rep.DryRun { fmt.Printf("%d issue(s) would be evicted, %d kept — nothing was touched\n", len(rep.Evicted), len(rep.Kept)) return } fmt.Printf("%d issue(s) evicted, %d kept\n", len(rep.Evicted), len(rep.Kept)) if rep.IndexPath != "" { fmt.Printf("index: %s — %d issue(s)\n", rep.IndexPath, rep.IndexCount) } }