Jaypore CI

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

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 }