package cli import ( "context" "flag" "fmt" "os" "path/filepath" "strings" "github.com/davis7dotsh/tx9/internal/box" "github.com/davis7dotsh/tx9/internal/docker" "github.com/davis7dotsh/tx9/internal/lock" "github.com/davis7dotsh/tx9/internal/state" "github.com/davis7dotsh/tx9/internal/version") // cmdPrune implements `tx9 prune`: garbage-collect stale tx9-box:* images// (superseded versions no container references) and orphaned per-box state// files (~/.tx9/boxes/*.env with no matching box left in the daemon).// Unlike other lifecycle commands, prune spans every box at once, so it// doesn't take a per-box lock — it only ever removes things nothing is// using.func cmdPrune(args []string) error { fs := flag.NewFlagSet("prune", flag.ContinueOnError) if err := parseFlagsAnywhere(fs, args); err != nil { return err } return withDocker(func(ctx context.Context, cli *docker.Client) error { removedImages, err := pruneImages(ctx, cli) if err != nil { return fmt.Errorf("prune: %w", err) } removedEnvFiles, err := pruneStateFiles(ctx, cli) if err != nil { return fmt.Errorf("prune: %w", err) } if len(removedImages) == 0 && len(removedEnvFiles) == 0 { fmt.Println("tx9: nothing to prune") return nil } for _, tag := range removedImages { fmt.Printf("tx9: removed unused image %s\n", tag) } for _, path := range removedEnvFiles { fmt.Printf("tx9: removed stale state file %s\n", path) } return nil })} // pruneImages removes tx9-box:* images whose version tag isn't the// running CLI's own version AND aren't referenced by any container tx9// still knows about (running or stopped — a stopped box still "uses" its// image). The current version's image is always kept even if unused,// since it's what the next `create`/`upgrade` needs.func pruneImages(ctx context.Context, cli *docker.Client) ([]string, error) { images, err := cli.ImageList(ctx, "tx9-box:*") if err != nil { return nil, err } containers, err := cli.ListBoxContainers(ctx) if err != nil { return nil, err } inUse := map[string]bool{} for _, c := range containers { inUse[c.Image] = true inUse[c.ImageID] = true } currentTag := fmt.Sprintf("tx9-box:%s", version.Version) var removed []string for _, img := range images { if inUse[img.ID] { continue } keep := false usedByTag := false for _, tag := range img.RepoTags { if tag == currentTag { keep = true } if inUse[tag] { usedByTag = true } } if keep || usedByTag { continue } if err := cli.ImageRemove(ctx, img.ID); err != nil { return removed, err } if len(img.RepoTags) > 0 { removed = append(removed, img.RepoTags[0]) } else { removed = append(removed, img.ID) } } return removed, nil} // pruneStateFiles removes ~/.tx9/boxes/<name>.env files with no// corresponding box left in the daemon — leftovers from a box deleted by// some other means (e.g. `docker rm` directly) that skipped tx9's own// cleanup.func pruneStateFiles(ctx context.Context, cli *docker.Client) ([]string, error) { boxes, err := box.List(ctx, cli) if err != nil { return nil, err } known := make(map[string]bool, len(boxes)) for _, b := range boxes { known[b.Name] = true } dir, err := state.BoxesDir() if err != nil { return nil, err } entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("read %s: %w", dir, err) } var removed []string for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".env") { continue } name := strings.TrimSuffix(e.Name(), ".env") if known[name] { continue } // An env file is written by create/import before either of them // takes this box's per-box lock and holds it for their entire // run, so a box with no containers yet (known[name] == false) // might just be mid-create/mid-import rather than truly // orphaned. Try the same non-blocking lock they use; if it's // held, skip this file instead of deleting state out from under // a running operation. lockPath, err := state.LockPath(name) if err != nil { return removed, err } release, err := lock.Acquire(lockPath) if err != nil { fmt.Printf("tx9: skipping %s: operation in progress\n", name) continue } path := filepath.Join(dir, e.Name()) rmErr := os.Remove(path) release() if rmErr != nil { return removed, fmt.Errorf("remove %s: %w", path, rmErr) } removed = append(removed, path) } return removed, nil}
package cli
import (
"context"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/davis7dotsh/tx9/internal/box"
"github.com/davis7dotsh/tx9/internal/docker"
"github.com/davis7dotsh/tx9/internal/lock"
"github.com/davis7dotsh/tx9/internal/state"
"github.com/davis7dotsh/tx9/internal/version"
)
// cmdPrune implements `tx9 prune`: garbage-collect stale tx9-box:* images
// (superseded versions no container references) and orphaned per-box state
// files (~/.tx9/boxes/*.env with no matching box left in the daemon).
// Unlike other lifecycle commands, prune spans every box at once, so it
// doesn't take a per-box lock — it only ever removes things nothing is
// using.
func cmdPrune(args []string) error {
fs := flag.NewFlagSet("prune", flag.ContinueOnError)
if err := parseFlagsAnywhere(fs, args); err != nil {
return err
}
return withDocker(func(ctx context.Context, cli *docker.Client) error {
removedImages, err := pruneImages(ctx, cli)
if err != nil {
return fmt.Errorf("prune: %w", err)
}
removedEnvFiles, err := pruneStateFiles(ctx, cli)
if err != nil {
return fmt.Errorf("prune: %w", err)
}
if len(removedImages) == 0 && len(removedEnvFiles) == 0 {
fmt.Println("tx9: nothing to prune")
return nil
}
for _, tag := range removedImages {
fmt.Printf("tx9: removed unused image %s\n", tag)
}
for _, path := range removedEnvFiles {
fmt.Printf("tx9: removed stale state file %s\n", path)
}
return nil
})
}
// pruneImages removes tx9-box:* images whose version tag isn't the
// running CLI's own version AND aren't referenced by any container tx9
// still knows about (running or stopped — a stopped box still "uses" its
// image). The current version's image is always kept even if unused,
// since it's what the next `create`/`upgrade` needs.
func pruneImages(ctx context.Context, cli *docker.Client) ([]string, error) {
images, err := cli.ImageList(ctx, "tx9-box:*")
if err != nil {
return nil, err
}
containers, err := cli.ListBoxContainers(ctx)
if err != nil {
return nil, err
}
inUse := map[string]bool{}
for _, c := range containers {
inUse[c.Image] = true
inUse[c.ImageID] = true
}
currentTag := fmt.Sprintf("tx9-box:%s", version.Version)
var removed []string
for _, img := range images {
if inUse[img.ID] {
continue
}
keep := false
usedByTag := false
for _, tag := range img.RepoTags {
if tag == currentTag {
keep = true
}
if inUse[tag] {
usedByTag = true
}
}
if keep || usedByTag {
continue
}
if err := cli.ImageRemove(ctx, img.ID); err != nil {
return removed, err
}
if len(img.RepoTags) > 0 {
removed = append(removed, img.RepoTags[0])
} else {
removed = append(removed, img.ID)
}
}
return removed, nil
}
// pruneStateFiles removes ~/.tx9/boxes/<name>.env files with no
// corresponding box left in the daemon — leftovers from a box deleted by
// some other means (e.g. `docker rm` directly) that skipped tx9's own
// cleanup.
func pruneStateFiles(ctx context.Context, cli *docker.Client) ([]string, error) {
boxes, err := box.List(ctx, cli)
if err != nil {
return nil, err
}
known := make(map[string]bool, len(boxes))
for _, b := range boxes {
known[b.Name] = true
}
dir, err := state.BoxesDir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read %s: %w", dir, err)
}
var removed []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".env") {
continue
}
name := strings.TrimSuffix(e.Name(), ".env")
if known[name] {
continue
}
// An env file is written by create/import before either of them
// takes this box's per-box lock and holds it for their entire
// run, so a box with no containers yet (known[name] == false)
// might just be mid-create/mid-import rather than truly
// orphaned. Try the same non-blocking lock they use; if it's
// held, skip this file instead of deleting state out from under
// a running operation.
lockPath, err := state.LockPath(name)
if err != nil {
return removed, err
}
release, err := lock.Acquire(lockPath)
if err != nil {
fmt.Printf("tx9: skipping %s: operation in progress\n", name)
continue
}
path := filepath.Join(dir, e.Name())
rmErr := os.Remove(path)
release()
if rmErr != nil {
return removed, fmt.Errorf("remove %s: %w", path, rmErr)
}
removed = append(removed, path)
}
return removed, nil
}