Jaypore CI

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

prune.go (10406B)


      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 (both jci and jci-runs)
    216 	out1, _ := git("ls-remote", remote, "refs/jci/*")
    217 	out2, _ := git("ls-remote", remote, "refs/jci-runs/*")
    218 	out := strings.TrimSpace(out1 + "\n" + out2)
    219 
    220 	if out == "" {
    221 		fmt.Println("No CI results on remote")
    222 		return nil
    223 	}
    224 
    225 	lines := strings.Split(out, "\n")
    226 	var refInfos []RefInfo
    227 
    228 	fmt.Println("Scanning remote CI results...")
    229 	for i, line := range lines {
    230 		if line == "" {
    231 			continue
    232 		}
    233 		printProgress(i+1, len(lines), "Scanning")
    234 
    235 		parts := strings.Fields(line)
    236 		if len(parts) != 2 {
    237 			continue
    238 		}
    239 
    240 		refName := parts[1]
    241 		commit := extractCommitFromRef(refName)
    242 
    243 		info := RefInfo{
    244 			Ref:    refName,
    245 			Commit: commit,
    246 		}
    247 
    248 		// Fetch this specific ref to get its timestamp
    249 		// We need to fetch it temporarily to inspect it
    250 		exec.Command("git", "fetch", remote, refName+":"+refName, "--quiet").Run()
    251 
    252 		timeStr, err := git("log", "-1", "--format=%ci", refName)
    253 		if err == nil {
    254 			info.Timestamp, _ = time.Parse("2006-01-02 15:04:05 -0700", timeStr)
    255 		}
    256 
    257 		info.Size = getRefSize(refName)
    258 		refInfos = append(refInfos, info)
    259 	}
    260 	fmt.Println() // newline after progress
    261 
    262 	// Filter refs to prune
    263 	var toPrune []RefInfo
    264 	var prunedSize int64
    265 	var totalSize int64
    266 	now := time.Now()
    267 
    268 	for _, info := range refInfos {
    269 		totalSize += info.Size
    270 		shouldPrune := false
    271 
    272 		// Check age if --older-than specified
    273 		if opts.OlderThan > 0 && !info.Timestamp.IsZero() {
    274 			age := now.Sub(info.Timestamp)
    275 			if age > opts.OlderThan {
    276 				shouldPrune = true
    277 			}
    278 		}
    279 
    280 		if shouldPrune {
    281 			toPrune = append(toPrune, info)
    282 			prunedSize += info.Size
    283 		}
    284 	}
    285 
    286 	if len(toPrune) == 0 {
    287 		fmt.Println("Nothing to prune on remote")
    288 		fmt.Printf("Total remote CI data: %s\n", formatSize(totalSize))
    289 		return nil
    290 	}
    291 
    292 	// Show what will be pruned
    293 	fmt.Printf("\nFound %d ref(s) to prune on %s:\n", len(toPrune), remote)
    294 	for _, info := range toPrune {
    295 		age := ""
    296 		if !info.Timestamp.IsZero() {
    297 			age = fmt.Sprintf(" (age: %s)", formatAge(now.Sub(info.Timestamp)))
    298 		}
    299 		fmt.Printf("  %s %s%s\n", info.Commit[:12], formatSize(info.Size), age)
    300 	}
    301 
    302 	fmt.Printf("\nTotal to free on remote: %s (of %s total)\n", formatSize(prunedSize), formatSize(totalSize))
    303 
    304 	if !opts.Commit {
    305 		fmt.Println("\n[DRY RUN] Use --commit to actually delete from remote")
    306 		return nil
    307 	}
    308 
    309 	// Delete from remote using git push with delete refspec
    310 	fmt.Println("\nDeleting from remote...")
    311 	deleted := 0
    312 	for i, info := range toPrune {
    313 		printProgress(i+1, len(toPrune), "Deleting")
    314 		// Push empty ref to delete
    315 		// Push empty ref to delete
    316 		_, err := git("push", remote, ":"+info.Ref)
    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 }
    404 
    405 // extractCommitFromRef extracts the commit hash from a JCI ref
    406 // refs/jci/<commit> -> <commit>
    407 // refs/jci-runs/<commit>/<runid> -> <commit>
    408 func extractCommitFromRef(ref string) string {
    409 	if strings.HasPrefix(ref, "refs/jci-runs/") {
    410 		// refs/jci-runs/<commit>/<runid>
    411 		parts := strings.Split(strings.TrimPrefix(ref, "refs/jci-runs/"), "/")
    412 		if len(parts) >= 1 {
    413 			return parts[0]
    414 		}
    415 	} else if strings.HasPrefix(ref, "refs/jci/") {
    416 		return strings.TrimPrefix(ref, "refs/jci/")
    417 	} else if strings.HasPrefix(ref, "jci/") {
    418 		return strings.TrimPrefix(ref, "jci/")
    419 	}
    420 	return ref
    421 }