Jaypore CI

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

commit 55d320c56b8ddf062df356d2907b45e31cf70af1
parent 2a38237c62da68b4f4c46d524865ef0a8bc7a327
Author: exe.dev user <exedev@jayporeci.exe.xyz>
Date:   Wed, 25 Feb 2026 04:00:19 +0000

x

Diffstat:
AREADME.md | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/git-jci/main.go | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago.mod | 3+++
Ainternal/jci/git.go | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/jci/prune.go | 44++++++++++++++++++++++++++++++++++++++++++++
Ainternal/jci/pull.go | 24++++++++++++++++++++++++
Ainternal/jci/push.go | 34++++++++++++++++++++++++++++++++++
Ainternal/jci/run.go | 272+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/jci/web.go | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 866 insertions(+), 0 deletions(-)

diff --git a/README.md b/README.md @@ -0,0 +1,111 @@ +# git-jci + +A local-first CI system that stores results in git's custom refs. + +## Installation + +```bash +go build -o git-jci ./cmd/git-jci +sudo mv git-jci /usr/local/bin/ +``` + +Once installed, git will automatically find it as a subcommand: + +```bash +git jci run +``` + +## Setup + +Create a `.jci/run.sh` script in your repository: + +```bash +mkdir -p .jci +cat > .jci/run.sh << 'EOF' +#!/bin/bash +set -e + +echo "Running tests..." +cd "$JCI_REPO_ROOT" && go test ./... + +echo "Building..." +cd "$JCI_REPO_ROOT" && go build -o "$JCI_OUTPUT_DIR/binary" ./cmd/... + +echo "Done!" +EOF +chmod +x .jci/run.sh +``` + +### Environment Variables + +Your `run.sh` script has access to: + +| Variable | Description | +|----------|-------------| +| `JCI_COMMIT` | Full commit hash | +| `JCI_REPO_ROOT` | Repository root path | +| `JCI_OUTPUT_DIR` | Output directory for artifacts | + +The script runs with `cwd` set to `JCI_OUTPUT_DIR`. Any files created there become CI artifacts. + +## Commands + +### `git jci run` + +Run CI for the current commit: + +```bash +git commit -m "My changes" +git jci run +``` + +This will: +1. Execute `.jci/run.sh` +2. Capture stdout/stderr to `run.output.txt` +3. Store all output files (artifacts) in `refs/jci/<commit>` +4. Generate an `index.html` with results + +### `git jci web [port]` + +Start a web server to view CI results. Default port is 8000. + +```bash +git jci web +git jci web 3000 +``` + +### `git jci push [remote]` + +Push CI results to a remote. Default remote is `origin`. + +```bash +git jci push +git jci push upstream +``` + +### `git jci pull [remote]` + +Fetch CI results from a remote. + +```bash +git jci pull +``` + +### `git jci prune` + +Remove CI results for commits that no longer exist in the repository. + +```bash +git jci prune +``` + +## How it works + +CI results are stored as git tree objects under the `refs/jci/` namespace. +This keeps them separate from your regular branches and tags, but still +part of the git repository. + +- Results are not checked out to the working directory +- They can be pushed/pulled like any other refs +- They are garbage collected when the original commit is gone (via `prune`) +- Each commit's CI output is stored as a separate commit object diff --git a/cmd/git-jci/main.go b/cmd/git-jci/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "os" + + "github.com/exedev/git-jci/internal/jci" +) + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + cmd := os.Args[1] + args := os.Args[2:] + + var err error + switch cmd { + case "run": + err = jci.Run(args) + case "web": + err = jci.Web(args) + case "push": + err = jci.Push(args) + case "pull": + err = jci.Pull(args) + case "prune": + err = jci.Prune(args) + case "help", "-h", "--help": + printUsage() + return + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) + printUsage() + os.Exit(1) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func printUsage() { + fmt.Println(`git-jci - Local-first CI system stored in git + +Usage: git jci <command> [options] + +Commands: + run Run CI for the current commit and store results + web Start a web server to view CI results + push Push CI results to remote + pull Pull CI results from remote + prune Remove old CI results + +CI results are stored in refs/jci/<commit> namespace.`) +} diff --git a/go.mod b/go.mod @@ -0,0 +1,3 @@ +module github.com/exedev/git-jci + +go 1.22.2 diff --git a/internal/jci/git.go b/internal/jci/git.go @@ -0,0 +1,144 @@ +package jci + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// git runs a git command and returns stdout +func git(args ...string) (string, error) { + cmd := exec.Command("git", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git %s: %v\n%s", strings.Join(args, " "), err, stderr.String()) + } + return strings.TrimSpace(stdout.String()), nil +} + +// GetCurrentCommit returns the current HEAD commit hash +func GetCurrentCommit() (string, error) { + return git("rev-parse", "HEAD") +} + +// GetRepoRoot returns the root directory of the git repository +func GetRepoRoot() (string, error) { + return git("rev-parse", "--show-toplevel") +} + +// RefExists checks if a ref exists +func RefExists(ref string) bool { + _, err := git("rev-parse", "--verify", ref) + return err == nil +} + +// StoreTree stores a directory as a tree object and creates a commit under refs/jci/<commit> +func StoreTree(dir string, commit string, message string) error { + repoRoot, err := GetRepoRoot() + if err != nil { + return err + } + + tmpIndex := repoRoot + "/.git/jci-index" + defer exec.Command("rm", "-f", tmpIndex).Run() + + // We need to use git hash-object and mktree to build a tree + // from files outside the repo + treeID, err := hashDir(dir, repoRoot, tmpIndex) + if err != nil { + return fmt.Errorf("failed to hash directory: %w", err) + } + + // Create commit from tree + commitTreeCmd := exec.Command("git", "commit-tree", treeID, "-m", message) + commitTreeCmd.Dir = repoRoot + commitOut, err := commitTreeCmd.Output() + if err != nil { + return fmt.Errorf("git commit-tree: %v", err) + } + commitID := strings.TrimSpace(string(commitOut)) + + // Update ref + ref := "refs/jci/" + commit + if _, err := git("update-ref", ref, commitID); err != nil { + return fmt.Errorf("git update-ref: %v", err) + } + + return nil +} + +// hashDir recursively hashes a directory and returns its tree ID +func hashDir(dir string, repoRoot string, tmpIndex string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + var treeEntries []string + + for _, entry := range entries { + path := filepath.Join(dir, entry.Name()) + + if entry.IsDir() { + // Recursively hash subdirectory + subTreeID, err := hashDir(path, repoRoot, tmpIndex) + if err != nil { + return "", err + } + treeEntries = append(treeEntries, fmt.Sprintf("040000 tree %s\t%s", subTreeID, entry.Name())) + } else { + // Hash file + cmd := exec.Command("git", "hash-object", "-w", path) + cmd.Dir = repoRoot + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("hash-object %s: %v", path, err) + } + blobID := strings.TrimSpace(string(out)) + + // Get file mode + info, err := entry.Info() + if err != nil { + return "", err + } + mode := "100644" + if info.Mode()&0111 != 0 { + mode = "100755" + } + treeEntries = append(treeEntries, fmt.Sprintf("%s blob %s\t%s", mode, blobID, entry.Name())) + } + } + + // Create tree from entries + treeInput := strings.Join(treeEntries, "\n") + if treeInput != "" { + treeInput += "\n" + } + + cmd := exec.Command("git", "mktree") + cmd.Dir = repoRoot + cmd.Stdin = strings.NewReader(treeInput) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("mktree: %v (input: %q)", err, treeInput) + } + + return strings.TrimSpace(string(out)), nil +} + +// ListJCIRefs returns all refs under refs/jci/ +func ListJCIRefs() ([]string, error) { + out, err := git("for-each-ref", "--format=%(refname:short)", "refs/jci/") + if err != nil { + return nil, err + } + if out == "" { + return nil, nil + } + return strings.Split(out, "\n"), nil +} diff --git a/internal/jci/prune.go b/internal/jci/prune.go @@ -0,0 +1,44 @@ +package jci + +import ( + "fmt" + "strings" +) + +// Prune removes CI results for commits that no longer exist +func Prune(args []string) error { + refs, err := ListJCIRefs() + if err != nil { + return err + } + + if len(refs) == 0 { + fmt.Println("No CI results to prune") + return nil + } + + pruned := 0 + for _, ref := range refs { + commit := strings.TrimPrefix(ref, "jci/") + + // Check if the commit still exists in the repo + _, err := git("cat-file", "-t", 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 + } + pruned++ + } + } + + if pruned == 0 { + fmt.Println("Nothing to prune") + } else { + fmt.Printf("Pruned %d CI result(s)\n", pruned) + } + + return nil +} diff --git a/internal/jci/pull.go b/internal/jci/pull.go @@ -0,0 +1,24 @@ +package jci + +import ( + "fmt" +) + +// Pull fetches CI results from remote +func Pull(args []string) error { + remote := "origin" + if len(args) > 0 { + remote = args[0] + } + + fmt.Printf("Fetching CI results from %s...\n", remote) + + // Fetch all refs/jci/* from remote + _, err := git("fetch", remote, "refs/jci/*:refs/jci/*") + if err != nil { + return err + } + + fmt.Println("Done") + return nil +} diff --git a/internal/jci/push.go b/internal/jci/push.go @@ -0,0 +1,34 @@ +package jci + +import ( + "fmt" +) + +// Push pushes CI results to remote +func Push(args []string) error { + remote := "origin" + if len(args) > 0 { + remote = args[0] + } + + refs, err := ListJCIRefs() + if err != nil { + return err + } + + if len(refs) == 0 { + fmt.Println("No CI results to push") + return nil + } + + fmt.Printf("Pushing %d CI result(s) to %s...\n", len(refs), remote) + + // Push all refs/jci/* to remote + _, err = git("push", remote, "refs/jci/*:refs/jci/*") + if err != nil { + return err + } + + fmt.Println("Done") + return nil +} diff --git a/internal/jci/run.go b/internal/jci/run.go @@ -0,0 +1,272 @@ +package jci + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "time" +) + +// Run executes CI for the current commit +func Run(args []string) error { + // Get current commit + commit, err := GetCurrentCommit() + if err != nil { + return fmt.Errorf("failed to get current commit: %w", err) + } + + fmt.Printf("Running CI for commit %s\n", commit[:12]) + + // Check if CI already ran for this commit + ref := "refs/jci/" + commit + if RefExists(ref) { + fmt.Printf("CI results already exist for %s\n", commit[:12]) + return nil + } + + repoRoot, err := GetRepoRoot() + if err != nil { + return fmt.Errorf("failed to get repo root: %w", err) + } + + // Check if .jci/run.sh exists + runScript := filepath.Join(repoRoot, ".jci", "run.sh") + if _, err := os.Stat(runScript); os.IsNotExist(err) { + return fmt.Errorf(".jci/run.sh not found - create it to define your CI pipeline") + } + + // Create output directory .jci/<commit> + outputDir := filepath.Join(repoRoot, ".jci", commit) + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output dir: %w", err) + } + + // Run CI + err = runCI(repoRoot, outputDir, commit) + // Continue even if CI fails - we still want to store the results + + // Generate index.html with results + if err := generateIndexHTML(outputDir, commit, err); err != nil { + fmt.Printf("Warning: failed to generate index.html: %v\n", err) + } + + // Store results in git + msg := fmt.Sprintf("CI results for %s", commit[:12]) + if storeErr := StoreTree(outputDir, commit, msg); storeErr != nil { + return fmt.Errorf("failed to store CI results: %w", storeErr) + } + + // Clean up the output directory after storing in git + os.RemoveAll(outputDir) + + fmt.Printf("CI results stored at %s\n", ref) + if err != nil { + return fmt.Errorf("CI failed (results stored): %w", err) + } + return nil +} + +// runCI executes .jci/run.sh and captures output +func runCI(repoRoot string, outputDir string, commit string) error { + runScript := filepath.Join(repoRoot, ".jci", "run.sh") + outputFile := filepath.Join(outputDir, "run.output.txt") + + // Create output file + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer f.Close() + + // Write header + fmt.Fprintf(f, "=== JCI Run Output ===\n") + fmt.Fprintf(f, "Commit: %s\n", commit) + fmt.Fprintf(f, "Started: %s\n", time.Now().Format(time.RFC3339)) + fmt.Fprintf(f, "======================\n\n") + + // Run the script + cmd := exec.Command("bash", runScript) + cmd.Dir = outputDir + cmd.Env = append(os.Environ(), + "JCI_COMMIT="+commit, + "JCI_REPO_ROOT="+repoRoot, + "JCI_OUTPUT_DIR="+outputDir, + ) + + // Capture both stdout and stderr to the same file + cmd.Stdout = f + cmd.Stderr = f + + fmt.Printf("Executing .jci/run.sh...\n") + startTime := time.Now() + runErr := cmd.Run() + duration := time.Since(startTime) + + // Write footer + fmt.Fprintf(f, "\n======================\n") + fmt.Fprintf(f, "Finished: %s\n", time.Now().Format(time.RFC3339)) + fmt.Fprintf(f, "Duration: %s\n", duration.Round(time.Millisecond)) + if runErr != nil { + fmt.Fprintf(f, "Exit: FAILED - %v\n", runErr) + } else { + fmt.Fprintf(f, "Exit: SUCCESS\n") + } + + return runErr +} + +// generateIndexHTML creates an index.html with CI results +func generateIndexHTML(outputDir string, commit string, ciErr error) error { + repoRoot, err := GetRepoRoot() + if err != nil { + return err + } + + // Get commit info + commitMsg, _ := git("log", "-1", "--format=%s", commit) + commitAuthor, _ := git("log", "-1", "--format=%an <%ae>", commit) + commitDate, _ := git("log", "-1", "--format=%ci", commit) + + // Read output file if it exists + outputContent := "" + outputFile := filepath.Join(outputDir, "run.output.txt") + if data, err := os.ReadFile(outputFile); err == nil { + outputContent = string(data) + } + + // List files in output directory + var files []string + entries, _ := os.ReadDir(outputDir) + for _, e := range entries { + if !e.IsDir() && e.Name() != "index.html" { + files = append(files, e.Name()) + } + } + + status := "success" + statusText := "✓ Passed" + if ciErr != nil { + status = "failed" + statusText = "✗ Failed" + } + + // Build files list HTML + filesHTML := "" + if len(files) > 0 { + filesHTML = "<h2>Artifacts</h2>\n<ul class=\"files\">\n" + for _, f := range files { + filesHTML += fmt.Sprintf("<li><a href=\"%s\">%s</a></li>\n", f, f) + } + filesHTML += "</ul>\n" + } + + html := fmt.Sprintf(`<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>CI Results - %s</title> + <style> + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + max-width: 900px; + margin: 40px auto; + padding: 0 20px; + background: #f5f5f5; + } + .container { + background: white; + border-radius: 8px; + padding: 24px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + h1 { color: #333; margin-top: 0; } + h2 { color: #555; margin-top: 24px; } + .commit-info { + background: #f8f9fa; + border-radius: 4px; + padding: 16px; + margin: 16px 0; + font-family: monospace; + font-size: 14px; + } + .commit-info p { margin: 4px 0; } + .label { color: #666; font-weight: bold; } + .status { + display: inline-block; + padding: 4px 12px; + border-radius: 4px; + font-weight: bold; + } + .status.success { background: #d4edda; color: #155724; } + .status.failed { background: #f8d7da; color: #721c24; } + .timestamp { color: #666; font-size: 0.9em; } + .output { + background: #1e1e1e; + color: #d4d4d4; + padding: 16px; + border-radius: 4px; + overflow-x: auto; + font-family: "Monaco", "Menlo", monospace; + font-size: 13px; + white-space: pre-wrap; + word-wrap: break-word; + max-height: 500px; + overflow-y: auto; + } + .files { list-style: none; padding: 0; } + .files li { + padding: 8px 12px; + border-bottom: 1px solid #eee; + } + .files li:last-child { border-bottom: none; } + .files a { color: #0366d6; text-decoration: none; } + .files a:hover { text-decoration: underline; } + </style> +</head> +<body> + <div class="container"> + <h1>CI Results</h1> + <p><span class="status %s">%s</span></p> + + <div class="commit-info"> + <p><span class="label">Commit:</span> %s</p> + <p><span class="label">Message:</span> %s</p> + <p><span class="label">Author:</span> %s</p> + <p><span class="label">Date:</span> %s</p> + </div> + + <p><span class="label">Repository:</span> %s</p> + <p class="timestamp">CI run at: %s</p> + + %s + + <h2>Output</h2> + <pre class="output">%s</pre> + </div> +</body> +</html> +`, commit[:12], status, statusText, commit, escapeHTML(commitMsg), escapeHTML(commitAuthor), commitDate, repoRoot, time.Now().Format(time.RFC3339), filesHTML, escapeHTML(outputContent)) + + indexPath := filepath.Join(outputDir, "index.html") + return os.WriteFile(indexPath, []byte(html), 0644) +} + +func escapeHTML(s string) string { + replacer := map[rune]string{ + '<': "&lt;", + '>': "&gt;", + '&': "&amp;", + '"': "&quot;", + '\'': "&#39;", + } + result := "" + for _, r := range s { + if rep, ok := replacer[r]; ok { + result += rep + } else { + result += string(r) + } + } + return result +} diff --git a/internal/jci/web.go b/internal/jci/web.go @@ -0,0 +1,175 @@ +package jci + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Web starts a web server to view CI results +func Web(args []string) error { + port := "8000" + if len(args) > 0 { + port = args[0] + } + + repoRoot, err := GetRepoRoot() + if err != nil { + return err + } + + fmt.Printf("Starting JCI web server on http://localhost:%s\n", port) + fmt.Println("Press Ctrl+C to stop") + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + handleRequest(w, r, repoRoot) + }) + + return http.ListenAndServe(":"+port, nil) +} + +func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) { + path := r.URL.Path + + // Root: list all CI runs + if path == "/" { + listRuns(w) + return + } + + // /jci/<commit>/... - serve files from that commit's CI results + if strings.HasPrefix(path, "/jci/") { + parts := strings.SplitN(strings.TrimPrefix(path, "/jci/"), "/", 2) + commit := parts[0] + filePath := "index.html" + if len(parts) > 1 && parts[1] != "" { + filePath = parts[1] + } + serveFromRef(w, commit, filePath) + return + } + + http.NotFound(w, r) +} + +func listRuns(w http.ResponseWriter) { + refs, err := ListJCIRefs() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>JCI - CI Runs</title> + <style> + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + max-width: 800px; + margin: 40px auto; + padding: 0 20px; + background: #f5f5f5; + } + .container { + background: white; + border-radius: 8px; + padding: 24px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + h1 { color: #333; margin-top: 0; } + ul { list-style: none; padding: 0; } + li { + padding: 12px; + border-bottom: 1px solid #eee; + } + li:last-child { border-bottom: none; } + a { color: #0366d6; text-decoration: none; font-family: monospace; } + a:hover { text-decoration: underline; } + .empty { color: #666; font-style: italic; } + </style> +</head> +<body> + <div class="container"> + <h1>JCI - CI Runs</h1> + <ul> +`) + + if len(refs) == 0 { + fmt.Fprint(w, `<li class="empty">No CI runs yet. Run 'git jci run' to create one.</li>`) + } else { + for _, ref := range refs { + commit := strings.TrimPrefix(ref, "jci/") + shortCommit := commit + if len(commit) > 12 { + shortCommit = commit[:12] + } + // Get commit message + msg, _ := git("log", "-1", "--format=%s", commit) + fmt.Fprintf(w, `<li><a href="/jci/%s/">%s</a> - %s</li>`, commit, shortCommit, msg) + } + } + + fmt.Fprint(w, ` + </ul> + </div> +</body> +</html> +`) +} + +func serveFromRef(w http.ResponseWriter, commit string, filePath string) { + ref := "refs/jci/" + commit + if !RefExists(ref) { + http.Error(w, "CI results not found for commit: "+commit, 404) + return + } + + // Use git show to get file content from the ref + cmd := exec.Command("git", "show", ref+":"+filePath) + out, err := cmd.Output() + if err != nil { + http.Error(w, "File not found: "+filePath, 404) + return + } + + // Set content type based on extension + ext := filepath.Ext(filePath) + switch ext { + case ".html": + w.Header().Set("Content-Type", "text/html") + case ".css": + w.Header().Set("Content-Type", "text/css") + case ".js": + w.Header().Set("Content-Type", "application/javascript") + case ".json": + w.Header().Set("Content-Type", "application/json") + default: + w.Header().Set("Content-Type", "text/plain") + } + + w.Write(out) +} + +// extractRef extracts files from a ref to a temp directory (not used currently but useful) +func extractRef(ref string) (string, error) { + tmpDir, err := os.MkdirTemp("", "jci-view-*") + if err != nil { + return "", err + } + + cmd := exec.Command("git", "archive", ref) + tar := exec.Command("tar", "-xf", "-", "-C", tmpDir) + + tar.Stdin, _ = cmd.StdoutPipe() + tar.Start() + cmd.Run() + tar.Wait() + + return tmpDir, nil +}