Jaypore CI

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

commit f2b07ef86732bdb3d2b5ff0e2b6838d7c7cea29c
parent c4d31fa72e0aeff6b6d6b32abe521d287451769a
Author: Arjoonn@exe.dev <arjoonn+exe.dev@midpathsoftware.com>
Date:   Wed, 25 Feb 2026 05:31:55 +0000

x

Diffstat:
MREADME.md | 16++++++++++++++++
Agit-jci | 0
Minternal/jci/prune.go | 387++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Minternal/jci/web.go | 6+++++-
4 files changed, 394 insertions(+), 15 deletions(-)

diff --git a/README.md b/README.md @@ -4,11 +4,27 @@ A local-first CI system that stores results in git's custom refs. ## Installation +### From source + ```bash go build -o git-jci ./cmd/git-jci sudo mv git-jci /usr/local/bin/ ``` +### From CI artifacts + +If CI has run, you can download the pre-built static binary: + +```bash +# One-liner: download and install from running JCI web server +curl -fsSL http://localhost:8000/jci/$(git rev-parse HEAD)/git-jci -o /tmp/git-jci && sudo install /tmp/git-jci /usr/local/bin/ + +# Or from a specific commit +curl -fsSL http://localhost:8000/jci/<commit>/git-jci -o /tmp/git-jci && sudo install /tmp/git-jci /usr/local/bin/ +``` + +The binary is fully static (no dependencies) and works on any Linux system. + Once installed, git will automatically find it as a subcommand: ```bash diff --git a/git-jci b/git-jci Binary files differ. diff --git a/internal/jci/prune.go b/internal/jci/prune.go @@ -2,11 +2,101 @@ package jci import ( "fmt" + "os/exec" + "regexp" + "strconv" "strings" + "time" ) -// Prune removes CI results for commits that no longer exist +// PruneOptions holds the options for the prune command +type PruneOptions struct { + Commit bool + OnRemote string + OlderThan time.Duration +} + +// ParsePruneArgs parses command line arguments for prune +func ParsePruneArgs(args []string) (*PruneOptions, error) { + opts := &PruneOptions{} + + for _, arg := range args { + if arg == "--commit" { + opts.Commit = true + } else if strings.HasPrefix(arg, "--on-remote=") { + opts.OnRemote = strings.TrimPrefix(arg, "--on-remote=") + } else if strings.HasPrefix(arg, "--on-remote") { + // Handle --on-remote origin (space separated) + continue + } else if strings.HasPrefix(arg, "--older-than=") { + durStr := strings.TrimPrefix(arg, "--older-than=") + dur, err := parseDuration(durStr) + if err != nil { + return nil, fmt.Errorf("invalid duration %q: %v", durStr, err) + } + opts.OlderThan = dur + } else if !strings.HasPrefix(arg, "-") && opts.OnRemote == "" { + // Check if previous arg was --on-remote + for i, a := range args { + if a == "--on-remote" && i+1 < len(args) && args[i+1] == arg { + opts.OnRemote = arg + break + } + } + } + } + + return opts, nil +} + +// parseDuration parses duration strings like "30d", "2w", "1h" +func parseDuration(s string) (time.Duration, error) { + re := regexp.MustCompile(`^(\d+)([dhwm])$`) + matches := re.FindStringSubmatch(s) + if matches == nil { + // Try standard Go duration + return time.ParseDuration(s) + } + + num, _ := strconv.Atoi(matches[1]) + unit := matches[2] + + switch unit { + case "d": + return time.Duration(num) * 24 * time.Hour, nil + case "w": + return time.Duration(num) * 7 * 24 * time.Hour, nil + case "m": + return time.Duration(num) * 30 * 24 * time.Hour, nil + case "h": + return time.Duration(num) * time.Hour, nil + } + + return 0, fmt.Errorf("unknown unit: %s", unit) +} + +// RefInfo holds information about a JCI ref +type RefInfo struct { + Ref string + Commit string + Timestamp time.Time + Size int64 +} + +// Prune removes CI results based on options func Prune(args []string) error { + opts, err := ParsePruneArgs(args) + if err != nil { + return err + } + + if opts.OnRemote != "" { + return pruneRemote(opts) + } + return pruneLocal(opts) +} + +func pruneLocal(opts *PruneOptions) error { refs, err := ListJCIRefs() if err != nil { return err @@ -17,28 +107,297 @@ func Prune(args []string) error { return nil } - pruned := 0 - for _, ref := range refs { + // Get info for all refs + var refInfos []RefInfo + var totalSize int64 + + fmt.Println("Scanning CI results...") + for i, ref := range refs { commit := strings.TrimPrefix(ref, "jci/") + printProgress(i+1, len(refs), "Scanning") + + info := RefInfo{ + Ref: ref, + Commit: commit, + } + + // Get timestamp from the JCI commit + timeStr, err := git("log", "-1", "--format=%ci", "refs/jci/"+commit) + if err == nil { + info.Timestamp, _ = time.Parse("2006-01-02 15:04:05 -0700", timeStr) + } - // Check if the commit still exists in the repo - _, err := git("cat-file", "-t", commit) + // Get size of the tree + info.Size = getRefSize("refs/jci/" + commit) + totalSize += info.Size + + refInfos = append(refInfos, info) + } + fmt.Println() // newline after progress + + // Filter refs to prune + var toPrune []RefInfo + var prunedSize int64 + now := time.Now() + + for _, info := range refInfos { + shouldPrune := false + + // Check if commit still exists (original behavior) + _, err := git("cat-file", "-t", info.Commit) if err != nil { - // Commit doesn't exist, remove the ref - fmt.Printf("Pruning %s (commit no longer exists)\n", commit[:12]) - if _, err := git("update-ref", "-d", "refs/jci/"+commit); err != nil { - fmt.Printf(" Warning: failed to delete ref: %v\n", err) - continue + shouldPrune = true + } + + // Check age if --older-than specified + if opts.OlderThan > 0 && !info.Timestamp.IsZero() { + age := now.Sub(info.Timestamp) + if age > opts.OlderThan { + shouldPrune = true } - pruned++ + } + + if shouldPrune { + toPrune = append(toPrune, info) + prunedSize += info.Size } } - if pruned == 0 { + if len(toPrune) == 0 { fmt.Println("Nothing to prune") - } else { - fmt.Printf("Pruned %d CI result(s)\n", pruned) + fmt.Printf("Total CI data: %s\n", formatSize(totalSize)) + return nil + } + + // Show what will be pruned + fmt.Printf("\nFound %d ref(s) to prune:\n", len(toPrune)) + for _, info := range toPrune { + age := "" + if !info.Timestamp.IsZero() { + age = fmt.Sprintf(" (age: %s)", formatAge(now.Sub(info.Timestamp))) + } + fmt.Printf(" %s %s%s\n", info.Commit[:12], formatSize(info.Size), age) + } + + fmt.Printf("\nTotal to free: %s (of %s total)\n", formatSize(prunedSize), formatSize(totalSize)) + + if !opts.Commit { + fmt.Println("\n[DRY RUN] Use --commit to actually delete") + return nil + } + + // Actually delete + fmt.Println("\nDeleting...") + deleted := 0 + for i, info := range toPrune { + printProgress(i+1, len(toPrune), "Deleting") + if _, err := git("update-ref", "-d", "refs/jci/"+info.Commit); err != nil { + fmt.Printf("\n Warning: failed to delete %s: %v\n", info.Commit[:12], err) + continue + } + deleted++ } + fmt.Println() // newline after progress + + // Run gc to actually free space + fmt.Println("Running git gc...") + exec.Command("git", "gc", "--prune=now", "--quiet").Run() + fmt.Printf("\nDeleted %d CI result(s), freed approximately %s\n", deleted, formatSize(prunedSize)) return nil } + +func pruneRemote(opts *PruneOptions) error { + remote := opts.OnRemote + + fmt.Printf("Fetching CI refs from %s...\n", remote) + + // Get remote refs + out, err := git("ls-remote", remote, "refs/jci/*") + if err != nil { + return fmt.Errorf("failed to list remote refs: %v", err) + } + + if out == "" { + fmt.Println("No CI results on remote") + return nil + } + + lines := strings.Split(out, "\n") + var refInfos []RefInfo + + fmt.Println("Scanning remote CI results...") + for i, line := range lines { + if line == "" { + continue + } + printProgress(i+1, len(lines), "Scanning") + + parts := strings.Fields(line) + if len(parts) != 2 { + continue + } + + refName := parts[1] + commit := strings.TrimPrefix(refName, "refs/jci/") + + info := RefInfo{ + Ref: refName, + Commit: commit, + } + + // Fetch this specific ref to get its timestamp + // We need to fetch it temporarily to inspect it + exec.Command("git", "fetch", remote, refName+":"+refName, "--quiet").Run() + + timeStr, err := git("log", "-1", "--format=%ci", refName) + if err == nil { + info.Timestamp, _ = time.Parse("2006-01-02 15:04:05 -0700", timeStr) + } + + info.Size = getRefSize(refName) + refInfos = append(refInfos, info) + } + fmt.Println() // newline after progress + + // Filter refs to prune + var toPrune []RefInfo + var prunedSize int64 + var totalSize int64 + now := time.Now() + + for _, info := range refInfos { + totalSize += info.Size + shouldPrune := false + + // Check age if --older-than specified + if opts.OlderThan > 0 && !info.Timestamp.IsZero() { + age := now.Sub(info.Timestamp) + if age > opts.OlderThan { + shouldPrune = true + } + } + + if shouldPrune { + toPrune = append(toPrune, info) + prunedSize += info.Size + } + } + + if len(toPrune) == 0 { + fmt.Println("Nothing to prune on remote") + fmt.Printf("Total remote CI data: %s\n", formatSize(totalSize)) + return nil + } + + // Show what will be pruned + fmt.Printf("\nFound %d ref(s) to prune on %s:\n", len(toPrune), remote) + for _, info := range toPrune { + age := "" + if !info.Timestamp.IsZero() { + age = fmt.Sprintf(" (age: %s)", formatAge(now.Sub(info.Timestamp))) + } + fmt.Printf(" %s %s%s\n", info.Commit[:12], formatSize(info.Size), age) + } + + fmt.Printf("\nTotal to free on remote: %s (of %s total)\n", formatSize(prunedSize), formatSize(totalSize)) + + if !opts.Commit { + fmt.Println("\n[DRY RUN] Use --commit to actually delete from remote") + return nil + } + + // Delete from remote using git push with delete refspec + fmt.Println("\nDeleting from remote...") + deleted := 0 + for i, info := range toPrune { + printProgress(i+1, len(toPrune), "Deleting") + // Push empty ref to delete + _, err := git("push", remote, ":refs/jci/"+info.Commit) + if err != nil { + fmt.Printf("\n Warning: failed to delete %s: %v\n", info.Commit[:12], err) + continue + } + deleted++ + } + fmt.Println() // newline after progress + + fmt.Printf("\nDeleted %d CI result(s) from %s, freed approximately %s\n", deleted, remote, formatSize(prunedSize)) + return nil +} + +// getRefSize estimates the size of objects in a ref +func getRefSize(ref string) int64 { + // Get the tree and estimate size + out, err := exec.Command("git", "rev-list", "--objects", ref).Output() + if err != nil { + return 0 + } + + var totalSize int64 + for _, line := range strings.Split(string(out), "\n") { + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) == 0 { + continue + } + obj := parts[0] + sizeOut, err := exec.Command("git", "cat-file", "-s", obj).Output() + if err == nil { + size, _ := strconv.ParseInt(strings.TrimSpace(string(sizeOut)), 10, 64) + totalSize += size + } + } + return totalSize +} + +// printProgress prints a progress bar +func printProgress(current, total int, label string) { + width := 30 + percent := float64(current) / float64(total) + filled := int(percent * float64(width)) + + bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + fmt.Printf("\r%s [%s] %d/%d (%.0f%%)", label, bar, current, total, percent*100) +} + +// formatSize formats bytes as human-readable +func formatSize(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +// formatAge formats a duration as human-readable age +func formatAge(d time.Duration) string { + days := int(d.Hours() / 24) + if days >= 365 { + years := days / 365 + return fmt.Sprintf("%dy", years) + } + if days >= 30 { + months := days / 30 + return fmt.Sprintf("%dmo", months) + } + if days >= 7 { + weeks := days / 7 + return fmt.Sprintf("%dw", weeks) + } + if days > 0 { + return fmt.Sprintf("%dd", days) + } + hours := int(d.Hours()) + if hours > 0 { + return fmt.Sprintf("%dh", hours) + } + return "<1h" +} diff --git a/internal/jci/web.go b/internal/jci/web.go @@ -530,8 +530,12 @@ func serveFromRef(w http.ResponseWriter, commit string, filePath string) { w.Header().Set("Content-Type", "application/javascript") case ".json": w.Header().Set("Content-Type", "application/json") - default: + case ".txt": w.Header().Set("Content-Type", "text/plain") + default: + // Binary files (executables, etc.) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(filePath))) } w.Write(out)