Jaypore CI

> Jaypore CI: Minimal, Offline, Local CI system.
Log | Files | Refs | README

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 }