commit 55d320c56b8ddf062df356d2907b45e31cf70af1
parent 2a38237c62da68b4f4c46d524865ef0a8bc7a327
Author: exe.dev user <exedev@jayporeci.exe.xyz>
Date: Wed, 25 Feb 2026 04:00:19 +0000
x
Diffstat:
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{
+ '<': "<",
+ '>': ">",
+ '&': "&",
+ '"': """,
+ '\'': "'",
+ }
+ 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
+}