Skip to content

Commit 309ef2b

Browse files
authored
feat(brew-management): add prune functionality to remove unlisted packages (#143)
- Introduced a new command `prune` to remove packages not defined in the YAML configuration. - Updated README.md to include usage examples for the `prune` command. - Added `PruneOptions` struct in types.go to support prune command configurations.
1 parent 6950b12 commit 309ef2b

File tree

4 files changed

+352
-0
lines changed

4 files changed

+352
-0
lines changed

scripts/brew-management/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ A command-line tool for managing Homebrew packages with support for groups, tags
88
- **Install**: Install packages from YAML configuration with filtering
99
- **Convert**: Convert Brewfile to YAML format
1010
- **Validate**: Validate YAML configuration files
11+
- **Prune**: Remove packages not defined in YAML configuration
1112
- **Generate**: Generate JSON schema from Go structs
1213

1314
## Schema Generation
@@ -103,6 +104,27 @@ Validate YAML configuration files:
103104
./brew-manager validate packages.yaml --verbose
104105
```
105106

107+
### Prune
108+
109+
Remove packages not defined in YAML configuration:
110+
111+
```bash
112+
# Show what would be removed (dry run)
113+
./brew-manager prune --dry-run
114+
115+
# Remove packages not in YAML
116+
./brew-manager prune
117+
118+
# Skip certain package types
119+
./brew-manager prune --skip-brews --skip-casks
120+
121+
# Remove all without confirmation
122+
./brew-manager prune --confirm-all
123+
124+
# Verbose output
125+
./brew-manager prune --verbose
126+
```
127+
106128
## Configuration Structure
107129

108130
The YAML configuration follows this structure:
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"brew-manager/pkg/types"
8+
"brew-manager/pkg/utils"
9+
"brew-manager/pkg/yaml"
10+
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var (
15+
skipTapsInPrune bool
16+
skipBrewsInPrune bool
17+
skipCasksInPrune bool
18+
skipMasInPrune bool
19+
confirmAll bool
20+
)
21+
22+
// pruneCmd represents the prune command
23+
var pruneCmd = &cobra.Command{
24+
Use: "prune [yaml_file]",
25+
Short: "Remove packages not defined in YAML configuration",
26+
Long: `Remove Homebrew packages that are currently installed but not defined in the YAML configuration file.
27+
28+
This command will:
29+
1. Read the YAML configuration file
30+
2. Compare with currently installed packages
31+
3. Remove packages that are not in the configuration
32+
33+
Examples:
34+
brew-manager prune # Use default packages.yaml
35+
brew-manager prune packages.yaml # Use specific YAML file
36+
brew-manager prune --dry-run # Show what would be removed
37+
brew-manager prune --skip-brews # Only remove casks, taps, and mas apps
38+
brew-manager prune --confirm-all # Remove all without individual confirmation`,
39+
Run: func(cmd *cobra.Command, args []string) {
40+
// Get YAML file path
41+
yamlFile := getDefaultYAMLPath("packages.yaml")
42+
if len(args) > 0 {
43+
yamlFile = args[0]
44+
}
45+
46+
// Build prune options
47+
options := &types.PruneOptions{
48+
DryRun: dryRun,
49+
Verbose: verbose,
50+
SkipTaps: skipTapsInPrune,
51+
SkipBrews: skipBrewsInPrune,
52+
SkipCasks: skipCasksInPrune,
53+
SkipMas: skipMasInPrune,
54+
ConfirmAll: confirmAll,
55+
}
56+
57+
// Perform prune
58+
if err := prunePackages(yamlFile, options); err != nil {
59+
utils.PrintStatus(utils.Red, fmt.Sprintf("Prune failed: %v", err))
60+
return
61+
}
62+
},
63+
}
64+
65+
func init() {
66+
rootCmd.AddCommand(pruneCmd)
67+
68+
// Prune-specific flags
69+
pruneCmd.Flags().BoolVar(&skipTapsInPrune, "skip-taps", false, "Skip removing taps")
70+
pruneCmd.Flags().BoolVar(&skipBrewsInPrune, "skip-brews", false, "Skip removing brew formulae")
71+
pruneCmd.Flags().BoolVar(&skipCasksInPrune, "skip-casks", false, "Skip removing casks")
72+
pruneCmd.Flags().BoolVar(&skipMasInPrune, "skip-mas", false, "Skip removing Mac App Store apps")
73+
pruneCmd.Flags().BoolVar(&confirmAll, "confirm-all", false, "Remove all packages without individual confirmation")
74+
}
75+
76+
// prunePackages removes packages not defined in the YAML configuration
77+
func prunePackages(yamlFile string, options *types.PruneOptions) error {
78+
// Load YAML configuration
79+
config, err := yaml.LoadGroupedConfig(yamlFile)
80+
if err != nil {
81+
return fmt.Errorf("failed to load YAML configuration: %w", err)
82+
}
83+
84+
// Get all packages from YAML
85+
yamlPackages := getAllPackagesFromConfig(config)
86+
87+
// Get currently installed packages
88+
installedPackages, installedMasApps, err := yaml.GetInstalledPackages()
89+
if err != nil {
90+
return fmt.Errorf("failed to get installed packages: %w", err)
91+
}
92+
93+
// Find packages to remove
94+
packagesToRemove := findPackagesToRemove(yamlPackages, installedPackages, installedMasApps, options)
95+
96+
if len(packagesToRemove["taps"]) == 0 && len(packagesToRemove["brews"]) == 0 &&
97+
len(packagesToRemove["casks"]) == 0 && len(packagesToRemove["mas"]) == 0 {
98+
utils.PrintStatus(utils.Green, "No packages to remove. All installed packages are defined in YAML configuration.")
99+
return nil
100+
}
101+
102+
// Show what will be removed
103+
showRemovalSummary(packagesToRemove)
104+
105+
if options.DryRun {
106+
utils.PrintStatus(utils.Yellow, "[DRY RUN] No packages were actually removed.")
107+
return nil
108+
}
109+
110+
// Confirm removal unless --confirm-all is used
111+
if !options.ConfirmAll {
112+
if !confirmRemoval() {
113+
utils.PrintStatus(utils.Yellow, "Prune operation cancelled.")
114+
return nil
115+
}
116+
}
117+
118+
// Remove packages in reverse order: mas, casks, brews, taps
119+
order := []string{"mas", "cask", "brew", "tap"}
120+
for _, pkgType := range order {
121+
if err := removePackagesByType(pkgType, packagesToRemove[pkgType], options); err != nil {
122+
return fmt.Errorf("failed to remove %s packages: %w", pkgType, err)
123+
}
124+
}
125+
126+
utils.PrintStatus(utils.Green, "Prune operation completed successfully.")
127+
return nil
128+
}
129+
130+
// getAllPackagesFromConfig extracts all packages from the configuration
131+
func getAllPackagesFromConfig(config *types.PackageGrouped) map[string]map[string]bool {
132+
result := map[string]map[string]bool{
133+
"tap": make(map[string]bool),
134+
"brew": make(map[string]bool),
135+
"cask": make(map[string]bool),
136+
"mas": make(map[string]bool),
137+
}
138+
139+
for _, group := range config.Groups {
140+
for _, pkg := range group.Packages {
141+
switch pkg.Type {
142+
case "tap":
143+
result["tap"][pkg.Name] = true
144+
case "brew":
145+
result["brew"][pkg.Name] = true
146+
case "cask":
147+
result["cask"][pkg.Name] = true
148+
case "mas":
149+
result["mas"][fmt.Sprintf("%d", pkg.ID)] = true
150+
}
151+
}
152+
}
153+
154+
return result
155+
}
156+
157+
// findPackagesToRemove identifies packages to remove
158+
func findPackagesToRemove(yamlPackages map[string]map[string]bool,
159+
installedPackages map[string][]string, installedMasApps []types.MasApp,
160+
options *types.PruneOptions) map[string][]string {
161+
162+
result := map[string][]string{
163+
"taps": []string{},
164+
"brews": []string{},
165+
"casks": []string{},
166+
"mas": []string{},
167+
}
168+
169+
// Check taps
170+
if !options.SkipTaps {
171+
for _, installed := range installedPackages["taps"] {
172+
if !yamlPackages["tap"][installed] {
173+
result["taps"] = append(result["taps"], installed)
174+
}
175+
}
176+
}
177+
178+
// Check brews
179+
if !options.SkipBrews {
180+
for _, installed := range installedPackages["brews"] {
181+
if !yamlPackages["brew"][installed] {
182+
result["brews"] = append(result["brews"], installed)
183+
}
184+
}
185+
}
186+
187+
// Check casks
188+
if !options.SkipCasks {
189+
for _, installed := range installedPackages["casks"] {
190+
if !yamlPackages["cask"][installed] {
191+
result["casks"] = append(result["casks"], installed)
192+
}
193+
}
194+
}
195+
196+
// Check mas apps
197+
if !options.SkipMas {
198+
for _, installed := range installedMasApps {
199+
idStr := fmt.Sprintf("%d", installed.ID)
200+
if !yamlPackages["mas"][idStr] {
201+
result["mas"] = append(result["mas"], fmt.Sprintf("%d (%s)", installed.ID, installed.Name))
202+
}
203+
}
204+
}
205+
206+
return result
207+
}
208+
209+
// showRemovalSummary displays what will be removed
210+
func showRemovalSummary(packagesToRemove map[string][]string) {
211+
utils.PrintStatus(utils.Blue, "Packages to be removed:")
212+
213+
if len(packagesToRemove["taps"]) > 0 {
214+
utils.PrintStatus(utils.Yellow, fmt.Sprintf("Taps (%d):", len(packagesToRemove["taps"])))
215+
for _, tap := range packagesToRemove["taps"] {
216+
fmt.Printf(" - %s\n", tap)
217+
}
218+
}
219+
220+
if len(packagesToRemove["brews"]) > 0 {
221+
utils.PrintStatus(utils.Yellow, fmt.Sprintf("Brew formulae (%d):", len(packagesToRemove["brews"])))
222+
for _, brew := range packagesToRemove["brews"] {
223+
fmt.Printf(" - %s\n", brew)
224+
}
225+
}
226+
227+
if len(packagesToRemove["casks"]) > 0 {
228+
utils.PrintStatus(utils.Yellow, fmt.Sprintf("Casks (%d):", len(packagesToRemove["casks"])))
229+
for _, cask := range packagesToRemove["casks"] {
230+
fmt.Printf(" - %s\n", cask)
231+
}
232+
}
233+
234+
if len(packagesToRemove["mas"]) > 0 {
235+
utils.PrintStatus(utils.Yellow, fmt.Sprintf("Mac App Store apps (%d):", len(packagesToRemove["mas"])))
236+
for _, mas := range packagesToRemove["mas"] {
237+
fmt.Printf(" - %s\n", mas)
238+
}
239+
}
240+
}
241+
242+
// confirmRemoval asks for user confirmation
243+
func confirmRemoval() bool {
244+
fmt.Print("\nAre you sure you want to remove these packages? [y/N]: ")
245+
var response string
246+
fmt.Scanln(&response)
247+
response = strings.ToLower(strings.TrimSpace(response))
248+
return response == "y" || response == "yes"
249+
}
250+
251+
// removePackagesByType removes packages of a specific type
252+
func removePackagesByType(pkgType string, packages []string, options *types.PruneOptions) error {
253+
if len(packages) == 0 {
254+
return nil
255+
}
256+
257+
utils.PrintStatus(utils.Blue, fmt.Sprintf("Removing %s packages...", pkgType))
258+
259+
for _, pkg := range packages {
260+
if options.Verbose {
261+
utils.PrintStatus(utils.Cyan, fmt.Sprintf("Processing %s: %s", pkgType, pkg))
262+
}
263+
264+
if err := removeSinglePackage(pkgType, pkg, options.Verbose); err != nil {
265+
utils.PrintStatus(utils.Red, fmt.Sprintf("Failed to remove %s: %s - %v", pkgType, pkg, err))
266+
continue
267+
}
268+
269+
utils.PrintStatus(utils.Green, fmt.Sprintf("Removed %s: %s", pkgType, pkg))
270+
}
271+
272+
return nil
273+
}
274+
275+
// removeSinglePackage removes a single package
276+
func removeSinglePackage(pkgType string, pkg string, verbose bool) error {
277+
switch pkgType {
278+
case "tap":
279+
return removeTap(pkg, verbose)
280+
case "brew":
281+
return removeBrew(pkg, verbose)
282+
case "cask":
283+
return removeCask(pkg, verbose)
284+
case "mas":
285+
// Extract ID from "ID (Name)" format
286+
parts := strings.SplitN(pkg, " ", 2)
287+
if len(parts) > 0 {
288+
return removeMas(parts[0], verbose)
289+
}
290+
return fmt.Errorf("invalid mas package format: %s", pkg)
291+
default:
292+
return fmt.Errorf("unknown package type: %s", pkgType)
293+
}
294+
}
295+
296+
// removeTap removes a tap
297+
func removeTap(name string, verbose bool) error {
298+
return utils.RunCommandSilent("brew", "untap", name)
299+
}
300+
301+
// removeBrew removes a brew formula
302+
func removeBrew(name string, verbose bool) error {
303+
return utils.RunCommandSilent("brew", "uninstall", name)
304+
}
305+
306+
// removeCask removes a cask
307+
func removeCask(name string, verbose bool) error {
308+
return utils.RunCommandSilent("brew", "uninstall", "--cask", name)
309+
}
310+
311+
// removeMas removes a Mac App Store app
312+
func removeMas(id string, verbose bool) error {
313+
if !utils.CommandExists("mas") {
314+
return fmt.Errorf("mas is not installed, cannot remove Mac App Store apps")
315+
}
316+
return utils.RunCommandSilent("mas", "uninstall", id)
317+
}

scripts/brew-management/cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ This tool provides a unified interface for all brew management operations includ
2626
- Synchronizing installed packages to YAML configuration
2727
- Converting Brewfile to YAML format
2828
- Validating YAML configuration files
29+
- Removing packages not defined in YAML configuration (prune)
2930
3031
Examples:
3132
brew-manager install --groups core,development
3233
brew-manager install --profile developer
3334
brew-manager sync --auto-detect
35+
brew-manager prune --dry-run
3436
brew-manager validate`,
3537
PersistentPreRun: func(cmd *cobra.Command, args []string) {
3638
// Check prerequisites for most commands

scripts/brew-management/pkg/types/types.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,15 @@ type ValidateOptions struct {
6969
Verbose bool
7070
All bool
7171
SchemaFile string
72+
}
73+
74+
// PruneOptions represents prune configuration
75+
type PruneOptions struct {
76+
DryRun bool
77+
Verbose bool
78+
SkipTaps bool
79+
SkipBrews bool
80+
SkipCasks bool
81+
SkipMas bool
82+
ConfirmAll bool
7283
}

0 commit comments

Comments
 (0)