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