prune.go (9763B)
1 package jci 2 3 import ( 4 "fmt" 5 "os/exec" 6 "regexp" 7 "strconv" 8 "strings" 9 "time" 10 ) 11 12 // PruneOptions holds the options for the prune command 13 type PruneOptions struct { 14 Commit bool 15 OnRemote string 16 OlderThan time.Duration 17 } 18 19 // ParsePruneArgs parses command line arguments for prune 20 func ParsePruneArgs(args []string) (*PruneOptions, error) { 21 opts := &PruneOptions{} 22 23 for _, arg := range args { 24 if arg == "--commit" { 25 opts.Commit = true 26 } else if strings.HasPrefix(arg, "--on-remote=") { 27 opts.OnRemote = strings.TrimPrefix(arg, "--on-remote=") 28 } else if strings.HasPrefix(arg, "--on-remote") { 29 // Handle --on-remote origin (space separated) 30 continue 31 } else if strings.HasPrefix(arg, "--older-than=") { 32 durStr := strings.TrimPrefix(arg, "--older-than=") 33 dur, err := parseDuration(durStr) 34 if err != nil { 35 return nil, fmt.Errorf("invalid duration %q: %v", durStr, err) 36 } 37 opts.OlderThan = dur 38 } else if !strings.HasPrefix(arg, "-") && opts.OnRemote == "" { 39 // Check if previous arg was --on-remote 40 for i, a := range args { 41 if a == "--on-remote" && i+1 < len(args) && args[i+1] == arg { 42 opts.OnRemote = arg 43 break 44 } 45 } 46 } 47 } 48 49 return opts, nil 50 } 51 52 // parseDuration parses duration strings like "30d", "2w", "1h" 53 func parseDuration(s string) (time.Duration, error) { 54 re := regexp.MustCompile(`^(\d+)([dhwm])$`) 55 matches := re.FindStringSubmatch(s) 56 if matches == nil { 57 // Try standard Go duration 58 return time.ParseDuration(s) 59 } 60 61 num, _ := strconv.Atoi(matches[1]) 62 unit := matches[2] 63 64 switch unit { 65 case "d": 66 return time.Duration(num) * 24 * time.Hour, nil 67 case "w": 68 return time.Duration(num) * 7 * 24 * time.Hour, nil 69 case "m": 70 return time.Duration(num) * 30 * 24 * time.Hour, nil 71 case "h": 72 return time.Duration(num) * time.Hour, nil 73 } 74 75 return 0, fmt.Errorf("unknown unit: %s", unit) 76 } 77 78 // RefInfo holds information about a JCI ref 79 type RefInfo struct { 80 Ref string 81 Commit string 82 Timestamp time.Time 83 Size int64 84 } 85 86 // Prune removes CI results based on options 87 func Prune(args []string) error { 88 opts, err := ParsePruneArgs(args) 89 if err != nil { 90 return err 91 } 92 93 if opts.OnRemote != "" { 94 return pruneRemote(opts) 95 } 96 return pruneLocal(opts) 97 } 98 99 func pruneLocal(opts *PruneOptions) error { 100 refs, err := ListJCIRefs() 101 if err != nil { 102 return err 103 } 104 105 if len(refs) == 0 { 106 fmt.Println("No CI results to prune") 107 return nil 108 } 109 110 // Get info for all refs 111 var refInfos []RefInfo 112 var totalSize int64 113 114 fmt.Println("Scanning CI results...") 115 for i, ref := range refs { 116 commit := strings.TrimPrefix(ref, "jci/") 117 printProgress(i+1, len(refs), "Scanning") 118 119 info := RefInfo{ 120 Ref: ref, 121 Commit: commit, 122 } 123 124 // Get timestamp from the JCI commit 125 timeStr, err := git("log", "-1", "--format=%ci", "refs/jci/"+commit) 126 if err == nil { 127 info.Timestamp, _ = time.Parse("2006-01-02 15:04:05 -0700", timeStr) 128 } 129 130 // Get size of the tree 131 info.Size = getRefSize("refs/jci/" + commit) 132 totalSize += info.Size 133 134 refInfos = append(refInfos, info) 135 } 136 fmt.Println() // newline after progress 137 138 // Filter refs to prune 139 var toPrune []RefInfo 140 var prunedSize int64 141 now := time.Now() 142 143 for _, info := range refInfos { 144 shouldPrune := false 145 146 // Check if commit still exists (original behavior) 147 _, err := git("cat-file", "-t", info.Commit) 148 if err != nil { 149 shouldPrune = true 150 } 151 152 // Check age if --older-than specified 153 if opts.OlderThan > 0 && !info.Timestamp.IsZero() { 154 age := now.Sub(info.Timestamp) 155 if age > opts.OlderThan { 156 shouldPrune = true 157 } 158 } 159 160 if shouldPrune { 161 toPrune = append(toPrune, info) 162 prunedSize += info.Size 163 } 164 } 165 166 if len(toPrune) == 0 { 167 fmt.Println("Nothing to prune") 168 fmt.Printf("Total CI data: %s\n", formatSize(totalSize)) 169 return nil 170 } 171 172 // Show what will be pruned 173 fmt.Printf("\nFound %d ref(s) to prune:\n", len(toPrune)) 174 for _, info := range toPrune { 175 age := "" 176 if !info.Timestamp.IsZero() { 177 age = fmt.Sprintf(" (age: %s)", formatAge(now.Sub(info.Timestamp))) 178 } 179 fmt.Printf(" %s %s%s\n", info.Commit[:12], formatSize(info.Size), age) 180 } 181 182 fmt.Printf("\nTotal to free: %s (of %s total)\n", formatSize(prunedSize), formatSize(totalSize)) 183 184 if !opts.Commit { 185 fmt.Println("\n[DRY RUN] Use --commit to actually delete") 186 return nil 187 } 188 189 // Actually delete 190 fmt.Println("\nDeleting...") 191 deleted := 0 192 for i, info := range toPrune { 193 printProgress(i+1, len(toPrune), "Deleting") 194 if _, err := git("update-ref", "-d", "refs/jci/"+info.Commit); err != nil { 195 fmt.Printf("\n Warning: failed to delete %s: %v\n", info.Commit[:12], err) 196 continue 197 } 198 deleted++ 199 } 200 fmt.Println() // newline after progress 201 202 // Run gc to actually free space 203 fmt.Println("Running git gc...") 204 exec.Command("git", "gc", "--prune=now", "--quiet").Run() 205 206 fmt.Printf("\nDeleted %d CI result(s), freed approximately %s\n", deleted, formatSize(prunedSize)) 207 return nil 208 } 209 210 func pruneRemote(opts *PruneOptions) error { 211 remote := opts.OnRemote 212 213 fmt.Printf("Fetching CI refs from %s...\n", remote) 214 215 // Get remote refs 216 out, err := git("ls-remote", remote, "refs/jci/*") 217 if err != nil { 218 return fmt.Errorf("failed to list remote refs: %v", err) 219 } 220 221 if out == "" { 222 fmt.Println("No CI results on remote") 223 return nil 224 } 225 226 lines := strings.Split(out, "\n") 227 var refInfos []RefInfo 228 229 fmt.Println("Scanning remote CI results...") 230 for i, line := range lines { 231 if line == "" { 232 continue 233 } 234 printProgress(i+1, len(lines), "Scanning") 235 236 parts := strings.Fields(line) 237 if len(parts) != 2 { 238 continue 239 } 240 241 refName := parts[1] 242 commit := strings.TrimPrefix(refName, "refs/jci/") 243 244 info := RefInfo{ 245 Ref: refName, 246 Commit: commit, 247 } 248 249 // Fetch this specific ref to get its timestamp 250 // We need to fetch it temporarily to inspect it 251 exec.Command("git", "fetch", remote, refName+":"+refName, "--quiet").Run() 252 253 timeStr, err := git("log", "-1", "--format=%ci", refName) 254 if err == nil { 255 info.Timestamp, _ = time.Parse("2006-01-02 15:04:05 -0700", timeStr) 256 } 257 258 info.Size = getRefSize(refName) 259 refInfos = append(refInfos, info) 260 } 261 fmt.Println() // newline after progress 262 263 // Filter refs to prune 264 var toPrune []RefInfo 265 var prunedSize int64 266 var totalSize int64 267 now := time.Now() 268 269 for _, info := range refInfos { 270 totalSize += info.Size 271 shouldPrune := false 272 273 // Check age if --older-than specified 274 if opts.OlderThan > 0 && !info.Timestamp.IsZero() { 275 age := now.Sub(info.Timestamp) 276 if age > opts.OlderThan { 277 shouldPrune = true 278 } 279 } 280 281 if shouldPrune { 282 toPrune = append(toPrune, info) 283 prunedSize += info.Size 284 } 285 } 286 287 if len(toPrune) == 0 { 288 fmt.Println("Nothing to prune on remote") 289 fmt.Printf("Total remote CI data: %s\n", formatSize(totalSize)) 290 return nil 291 } 292 293 // Show what will be pruned 294 fmt.Printf("\nFound %d ref(s) to prune on %s:\n", len(toPrune), remote) 295 for _, info := range toPrune { 296 age := "" 297 if !info.Timestamp.IsZero() { 298 age = fmt.Sprintf(" (age: %s)", formatAge(now.Sub(info.Timestamp))) 299 } 300 fmt.Printf(" %s %s%s\n", info.Commit[:12], formatSize(info.Size), age) 301 } 302 303 fmt.Printf("\nTotal to free on remote: %s (of %s total)\n", formatSize(prunedSize), formatSize(totalSize)) 304 305 if !opts.Commit { 306 fmt.Println("\n[DRY RUN] Use --commit to actually delete from remote") 307 return nil 308 } 309 310 // Delete from remote using git push with delete refspec 311 fmt.Println("\nDeleting from remote...") 312 deleted := 0 313 for i, info := range toPrune { 314 printProgress(i+1, len(toPrune), "Deleting") 315 // Push empty ref to delete 316 _, err := git("push", remote, ":refs/jci/"+info.Commit) 317 if err != nil { 318 fmt.Printf("\n Warning: failed to delete %s: %v\n", info.Commit[:12], err) 319 continue 320 } 321 deleted++ 322 } 323 fmt.Println() // newline after progress 324 325 fmt.Printf("\nDeleted %d CI result(s) from %s, freed approximately %s\n", deleted, remote, formatSize(prunedSize)) 326 return nil 327 } 328 329 // getRefSize estimates the size of objects in a ref 330 func getRefSize(ref string) int64 { 331 // Get the tree and estimate size 332 out, err := exec.Command("git", "rev-list", "--objects", ref).Output() 333 if err != nil { 334 return 0 335 } 336 337 var totalSize int64 338 for _, line := range strings.Split(string(out), "\n") { 339 if line == "" { 340 continue 341 } 342 parts := strings.Fields(line) 343 if len(parts) == 0 { 344 continue 345 } 346 obj := parts[0] 347 sizeOut, err := exec.Command("git", "cat-file", "-s", obj).Output() 348 if err == nil { 349 size, _ := strconv.ParseInt(strings.TrimSpace(string(sizeOut)), 10, 64) 350 totalSize += size 351 } 352 } 353 return totalSize 354 } 355 356 // printProgress prints a progress bar 357 func printProgress(current, total int, label string) { 358 width := 30 359 percent := float64(current) / float64(total) 360 filled := int(percent * float64(width)) 361 362 bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) 363 fmt.Printf("\r%s [%s] %d/%d (%.0f%%)", label, bar, current, total, percent*100) 364 } 365 366 // formatSize formats bytes as human-readable 367 func formatSize(bytes int64) string { 368 const unit = 1024 369 if bytes < unit { 370 return fmt.Sprintf("%d B", bytes) 371 } 372 div, exp := int64(unit), 0 373 for n := bytes / unit; n >= unit; n /= unit { 374 div *= unit 375 exp++ 376 } 377 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 378 } 379 380 // formatAge formats a duration as human-readable age 381 func formatAge(d time.Duration) string { 382 days := int(d.Hours() / 24) 383 if days >= 365 { 384 years := days / 365 385 return fmt.Sprintf("%dy", years) 386 } 387 if days >= 30 { 388 months := days / 30 389 return fmt.Sprintf("%dmo", months) 390 } 391 if days >= 7 { 392 weeks := days / 7 393 return fmt.Sprintf("%dw", weeks) 394 } 395 if days > 0 { 396 return fmt.Sprintf("%dd", days) 397 } 398 hours := int(d.Hours()) 399 if hours > 0 { 400 return fmt.Sprintf("%dh", hours) 401 } 402 return "<1h" 403 }