commit 41e49247af2ee8f30b45cad1d31454498cdda584
parent 3f8b5cea3a9d67942dbfadaed000430b0e4ffcf2
Author: Arjoonn@exe.dev <arjoonn+exe.dev@midpathsoftware.com>
Date: Wed, 25 Feb 2026 07:14:45 +0000
Add cron facility and multi-run support
- Add .jci/crontab file format for scheduling CI runs
- Add 'git jci cron ls' to list configured and installed cron jobs
- Add 'git jci cron sync' to sync crontab with system cron
- Add --multi flag to 'git jci run' for multiple runs per commit
- Multi-run results stored in refs/jci-runs/<commit>/<timestamp>-<random>
- Update push/pull/prune to handle both refs/jci/ and refs/jci-runs/
- Update web UI to show multi-run results
Crontab format:
# comment
SCHEDULE [branch:BRANCH] [name:NAME]
0 * * * * branch:main name:hourly
*/15 * * * * name:quick-check
Co-authored-by: Shelley <shelley@exe.dev>
Diffstat:
8 files changed, 562 insertions(+), 56 deletions(-)
diff --git a/cmd/git-jci/main.go b/cmd/git-jci/main.go
@@ -28,6 +28,8 @@ func main() {
err = jci.Pull(args)
case "prune":
err = jci.Prune(args)
+ case "cron":
+ err = jci.Cron(args)
case "help", "-h", "--help":
printUsage()
return
@@ -49,11 +51,15 @@ func printUsage() {
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
+ run [--multi] Run CI for the current commit and store results
+ --multi: Allow multiple runs per commit (uses timestamp ID)
+ 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
+ cron ls List cron jobs for this repository
+ cron sync Sync .jci/crontab with system cron
-CI results are stored in refs/jci/<commit> namespace.`)
+CI results are stored in refs/jci/<commit> namespace.
+With --multi, results are stored in refs/jci/<commit>/<runid>.`)
}
diff --git a/internal/jci/cron.go b/internal/jci/cron.go
@@ -0,0 +1,298 @@
+package jci
+
+import (
+ "bufio"
+ "bytes"
+ "crypto/sha256"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+const cronMarkerPrefix = "# JCI:"
+
+// CronEntry represents a parsed crontab entry
+type CronEntry struct {
+ Schedule string // e.g., "0 * * * *"
+ Name string // optional name/comment
+ Branch string // branch to run on (default: current)
+}
+
+// Cron handles cron subcommands
+func Cron(args []string) error {
+ if len(args) == 0 {
+ return fmt.Errorf("usage: git jci cron <ls|sync>")
+ }
+
+ switch args[0] {
+ case "ls":
+ return cronList()
+ case "sync":
+ return cronSync()
+ default:
+ return fmt.Errorf("unknown cron command: %s (use ls or sync)", args[0])
+ }
+}
+
+// cronList shows current cron jobs from .jci/crontab and system cron
+func cronList() error {
+ repoRoot, err := GetRepoRoot()
+ if err != nil {
+ return fmt.Errorf("not in a git repository: %w", err)
+ }
+
+ repoID := getRepoID(repoRoot)
+
+ // Show .jci/crontab entries
+ crontabFile := filepath.Join(repoRoot, ".jci", "crontab")
+ entries, err := parseCrontab(crontabFile)
+ if err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to parse .jci/crontab: %w", err)
+ }
+
+ fmt.Printf("Repository: %s\n", repoRoot)
+ fmt.Printf("Repo ID: %s\n\n", repoID[:12])
+
+ if len(entries) == 0 {
+ fmt.Println("No entries in .jci/crontab")
+ } else {
+ fmt.Println("Configured in .jci/crontab:")
+ for _, e := range entries {
+ branch := e.Branch
+ if branch == "" {
+ branch = "(current)"
+ }
+ name := e.Name
+ if name == "" {
+ name = "(unnamed)"
+ }
+ fmt.Printf(" %-20s %-15s %s\n", e.Schedule, branch, name)
+ }
+ }
+
+ // Show what's in system crontab
+ fmt.Println("\nInstalled in system cron:")
+ systemEntries, err := getSystemCronEntries(repoID)
+ if err != nil {
+ fmt.Printf(" (could not read system cron: %v)\n", err)
+ } else if len(systemEntries) == 0 {
+ fmt.Println(" (none)")
+ } else {
+ for _, line := range systemEntries {
+ fmt.Printf(" %s\n", line)
+ }
+ }
+
+ return nil
+}
+
+// cronSync synchronizes .jci/crontab with system cron
+func cronSync() error {
+ repoRoot, err := GetRepoRoot()
+ if err != nil {
+ return fmt.Errorf("not in a git repository: %w", err)
+ }
+
+ repoID := getRepoID(repoRoot)
+ marker := cronMarkerPrefix + repoID
+
+ // Parse .jci/crontab
+ crontabFile := filepath.Join(repoRoot, ".jci", "crontab")
+ entries, err := parseCrontab(crontabFile)
+ if err != nil {
+ if os.IsNotExist(err) {
+ entries = nil // No crontab file = remove all entries
+ } else {
+ return fmt.Errorf("failed to parse .jci/crontab: %w", err)
+ }
+ }
+
+ // Get current system crontab
+ currentCron, err := getCurrentCrontab()
+ if err != nil {
+ return fmt.Errorf("failed to read current crontab: %w", err)
+ }
+
+ // Remove old JCI entries for this repo
+ var newLines []string
+ for _, line := range strings.Split(currentCron, "\n") {
+ if !strings.Contains(line, marker) {
+ newLines = append(newLines, line)
+ }
+ }
+
+ // Find git-jci binary path
+ jciBinary, err := findJCIBinary()
+ if err != nil {
+ return fmt.Errorf("could not find git-jci binary: %w", err)
+ }
+
+ // Add new entries
+ for _, e := range entries {
+ cmd := fmt.Sprintf("cd %s && git fetch --quiet 2>/dev/null; ", shellEscape(repoRoot))
+ if e.Branch != "" {
+ cmd += fmt.Sprintf("git checkout --quiet %s 2>/dev/null && git pull --quiet 2>/dev/null; ", shellEscape(e.Branch))
+ }
+ cmd += fmt.Sprintf("%s run --multi", jciBinary)
+
+ comment := e.Name
+ if comment == "" {
+ comment = "jci"
+ }
+
+ line := fmt.Sprintf("%s %s %s [%s]", e.Schedule, cmd, marker, comment)
+ newLines = append(newLines, line)
+ }
+
+ // Write new crontab
+ newCron := strings.Join(newLines, "\n")
+ // Clean up multiple empty lines
+ for strings.Contains(newCron, "\n\n\n") {
+ newCron = strings.ReplaceAll(newCron, "\n\n\n", "\n\n")
+ }
+ newCron = strings.TrimSpace(newCron) + "\n"
+
+ if err := installCrontab(newCron); err != nil {
+ return fmt.Errorf("failed to install crontab: %w", err)
+ }
+
+ if len(entries) == 0 {
+ fmt.Printf("Removed all JCI cron entries for %s\n", repoRoot)
+ } else {
+ fmt.Printf("Synced %d cron entries for %s\n", len(entries), repoRoot)
+ }
+
+ return nil
+}
+
+// parseCrontab parses a .jci/crontab file
+// Format:
+// # comment
+// SCHEDULE [branch:BRANCH] [name:NAME]
+// 0 * * * * # every hour, current branch
+// 0 0 * * * branch:main # daily at midnight, main branch
+// */15 * * * * name:quick-test # every 15 min
+func parseCrontab(path string) ([]CronEntry, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ var entries []CronEntry
+ scanner := bufio.NewScanner(f)
+ scheduleRe := regexp.MustCompile(`^([*0-9,/-]+\s+[*0-9,/-]+\s+[*0-9,/-]+\s+[*0-9,/-]+\s+[*0-9,/-]+)\s*(.*)`)
+
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+
+ // Skip empty lines and comments
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ matches := scheduleRe.FindStringSubmatch(line)
+ if matches == nil {
+ continue // Invalid line, skip
+ }
+
+ entry := CronEntry{
+ Schedule: matches[1],
+ }
+
+ // Parse options
+ opts := matches[2]
+ for _, part := range strings.Fields(opts) {
+ if strings.HasPrefix(part, "branch:") {
+ entry.Branch = strings.TrimPrefix(part, "branch:")
+ } else if strings.HasPrefix(part, "name:") {
+ entry.Name = strings.TrimPrefix(part, "name:")
+ }
+ }
+
+ entries = append(entries, entry)
+ }
+
+ return entries, scanner.Err()
+}
+
+// getRepoID generates a unique ID for a repository based on its path
+func getRepoID(repoRoot string) string {
+ h := sha256.Sum256([]byte(repoRoot))
+ return fmt.Sprintf("%x", h)
+}
+
+// getCurrentCrontab returns the current user's crontab
+func getCurrentCrontab() (string, error) {
+ cmd := exec.Command("crontab", "-l")
+ out, err := cmd.Output()
+ if err != nil {
+ // No crontab for user is not an error
+ if strings.Contains(err.Error(), "no crontab") {
+ return "", nil
+ }
+ // Check stderr for "no crontab" message
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ if strings.Contains(string(exitErr.Stderr), "no crontab") {
+ return "", nil
+ }
+ }
+ return "", err
+ }
+ return string(out), nil
+}
+
+// installCrontab installs a new crontab
+func installCrontab(content string) error {
+ cmd := exec.Command("crontab", "-")
+ cmd.Stdin = strings.NewReader(content)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("%v: %s", err, stderr.String())
+ }
+ return nil
+}
+
+// getSystemCronEntries returns JCI entries for this repo from system cron
+func getSystemCronEntries(repoID string) ([]string, error) {
+ current, err := getCurrentCrontab()
+ if err != nil {
+ return nil, err
+ }
+
+ marker := cronMarkerPrefix + repoID
+ var entries []string
+ for _, line := range strings.Split(current, "\n") {
+ if strings.Contains(line, marker) {
+ entries = append(entries, line)
+ }
+ }
+ return entries, nil
+}
+
+// findJCIBinary finds the path to git-jci binary
+func findJCIBinary() (string, error) {
+ // First try to find ourselves
+ exe, err := os.Executable()
+ if err == nil {
+ return exe, nil
+ }
+
+ // Try PATH
+ path, err := exec.LookPath("git-jci")
+ if err == nil {
+ return path, nil
+ }
+
+ return "", fmt.Errorf("git-jci not found in PATH")
+}
+
+// shellEscape escapes a string for safe use in shell
+func shellEscape(s string) string {
+ // Simple escaping - wrap in single quotes and escape existing single quotes
+ return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
+}
diff --git a/internal/jci/git.go b/internal/jci/git.go
@@ -38,7 +38,8 @@ func RefExists(ref string) bool {
}
// 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 {
+// If runID is non-empty, stores under refs/jci/<commit>/<runID> instead
+func StoreTree(dir string, commit string, message string, runID string) error {
repoRoot, err := GetRepoRoot()
if err != nil {
return err
@@ -64,7 +65,12 @@ func StoreTree(dir string, commit string, message string) error {
commitID := strings.TrimSpace(string(commitOut))
// Update ref
+ // Single runs: refs/jci/<commit>
+ // Multi runs: refs/jci-runs/<commit>/<runid>
ref := "refs/jci/" + commit
+ if runID != "" {
+ ref = "refs/jci-runs/" + commit + "/" + runID
+ }
if _, err := git("update-ref", ref, commitID); err != nil {
return fmt.Errorf("git update-ref: %v", err)
}
@@ -142,3 +148,34 @@ func ListJCIRefs() ([]string, error) {
}
return strings.Split(out, "\n"), nil
}
+
+// ListJCIRunRefs returns all refs under refs/jci-runs/
+func ListJCIRunRefs() ([]string, error) {
+ out, err := git("for-each-ref", "--format=%(refname)", "refs/jci-runs/")
+ if err != nil {
+ return nil, err
+ }
+ if out == "" {
+ return nil, nil
+ }
+ return strings.Split(out, "\n"), nil
+}
+
+// ListAllJCIRefs returns all JCI refs (both single and multi-run)
+func ListAllJCIRefs() ([]string, error) {
+ var allRefs []string
+
+ // Get refs/jci/*
+ out, err := git("for-each-ref", "--format=%(refname)", "refs/jci/")
+ if err == nil && out != "" {
+ allRefs = append(allRefs, strings.Split(out, "\n")...)
+ }
+
+ // Get refs/jci-runs/*
+ out, err = git("for-each-ref", "--format=%(refname)", "refs/jci-runs/")
+ if err == nil && out != "" {
+ allRefs = append(allRefs, strings.Split(out, "\n")...)
+ }
+
+ return allRefs, nil
+}
diff --git a/internal/jci/prune.go b/internal/jci/prune.go
@@ -212,11 +212,10 @@ func pruneRemote(opts *PruneOptions) error {
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)
- }
+ // Get remote refs (both jci and jci-runs)
+ out1, _ := git("ls-remote", remote, "refs/jci/*")
+ out2, _ := git("ls-remote", remote, "refs/jci-runs/*")
+ out := strings.TrimSpace(out1 + "\n" + out2)
if out == "" {
fmt.Println("No CI results on remote")
@@ -239,7 +238,7 @@ func pruneRemote(opts *PruneOptions) error {
}
refName := parts[1]
- commit := strings.TrimPrefix(refName, "refs/jci/")
+ commit := extractCommitFromRef(refName)
info := RefInfo{
Ref: refName,
@@ -313,7 +312,8 @@ func pruneRemote(opts *PruneOptions) error {
for i, info := range toPrune {
printProgress(i+1, len(toPrune), "Deleting")
// Push empty ref to delete
- _, err := git("push", remote, ":refs/jci/"+info.Commit)
+ // Push empty ref to delete
+ _, err := git("push", remote, ":"+info.Ref)
if err != nil {
fmt.Printf("\n Warning: failed to delete %s: %v\n", info.Commit[:12], err)
continue
@@ -401,3 +401,21 @@ func formatAge(d time.Duration) string {
}
return "<1h"
}
+
+// extractCommitFromRef extracts the commit hash from a JCI ref
+// refs/jci/<commit> -> <commit>
+// refs/jci-runs/<commit>/<runid> -> <commit>
+func extractCommitFromRef(ref string) string {
+ if strings.HasPrefix(ref, "refs/jci-runs/") {
+ // refs/jci-runs/<commit>/<runid>
+ parts := strings.Split(strings.TrimPrefix(ref, "refs/jci-runs/"), "/")
+ if len(parts) >= 1 {
+ return parts[0]
+ }
+ } else if strings.HasPrefix(ref, "refs/jci/") {
+ return strings.TrimPrefix(ref, "refs/jci/")
+ } else if strings.HasPrefix(ref, "jci/") {
+ return strings.TrimPrefix(ref, "jci/")
+ }
+ return ref
+}
diff --git a/internal/jci/pull.go b/internal/jci/pull.go
@@ -16,7 +16,13 @@ func Pull(args []string) error {
// Fetch all refs/jci/* from remote
_, err := git("fetch", remote, "refs/jci/*:refs/jci/*")
if err != nil {
- return err
+ fmt.Printf("Warning: %v\n", err)
+ }
+
+ // Fetch all refs/jci-runs/* from remote
+ _, err = git("fetch", remote, "refs/jci-runs/*:refs/jci-runs/*")
+ if err != nil {
+ fmt.Printf("Warning: %v\n", err)
}
fmt.Println("Done")
diff --git a/internal/jci/push.go b/internal/jci/push.go
@@ -12,8 +12,8 @@ func Push(args []string) error {
remote = args[0]
}
- // Get local refs
- localRefs, err := ListJCIRefs()
+ // Get all local JCI refs
+ localRefs, err := ListAllJCIRefs()
if err != nil {
return err
}
@@ -29,9 +29,8 @@ func Push(args []string) error {
// Find refs that need to be pushed
var toPush []string
for _, ref := range localRefs {
- commit := strings.TrimPrefix(ref, "jci/")
- if !remoteRefs[commit] {
- toPush = append(toPush, "refs/jci/"+commit)
+ if !remoteRefs[ref] {
+ toPush = append(toPush, ref)
}
}
@@ -55,25 +54,31 @@ func Push(args []string) error {
return nil
}
-// getRemoteJCIRefs returns a set of commits that have CI refs on the remote
+// getRemoteJCIRefs returns a set of refs that exist on the remote
func getRemoteJCIRefs(remote string) map[string]bool {
remoteCI := make(map[string]bool)
+ // Get refs/jci/*
out, err := git("ls-remote", "--refs", remote, "refs/jci/*")
- if err != nil {
- return remoteCI
- }
- if out == "" {
- return remoteCI
+ if err == nil && out != "" {
+ for _, line := range strings.Split(out, "\n") {
+ parts := strings.Fields(line)
+ if len(parts) >= 2 {
+ remoteCI[parts[1]] = true
+ }
+ }
}
- for _, line := range strings.Split(out, "\n") {
- parts := strings.Fields(line)
- if len(parts) >= 2 {
- ref := parts[1]
- commit := strings.TrimPrefix(ref, "refs/jci/")
- remoteCI[commit] = true
+ // Get refs/jci-runs/*
+ out, err = git("ls-remote", "--refs", remote, "refs/jci-runs/*")
+ if err == nil && out != "" {
+ for _, line := range strings.Split(out, "\n") {
+ parts := strings.Fields(line)
+ if len(parts) >= 2 {
+ remoteCI[parts[1]] = true
+ }
}
}
+
return remoteCI
}
diff --git a/internal/jci/run.go b/internal/jci/run.go
@@ -1,6 +1,8 @@
package jci
import (
+ "crypto/rand"
+ "encoding/hex"
"fmt"
"os"
"os/exec"
@@ -9,7 +11,16 @@ import (
)
// Run executes CI for the current commit
+// With --multi flag, allows multiple runs per commit (uses timestamp+random suffix)
func Run(args []string) error {
+ // Parse flags
+ multiRun := false
+ for _, arg := range args {
+ if arg == "--multi" {
+ multiRun = true
+ }
+ }
+
// Get current commit
commit, err := GetCurrentCommit()
if err != nil {
@@ -18,11 +29,18 @@ func Run(args []string) error {
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
+ // Generate run ID for multi-run mode
+ var runID string
+ if multiRun {
+ runID = generateRunID()
+ fmt.Printf("Run ID: %s\n", runID)
+ } else {
+ // Check if CI already ran for this commit (single-run mode)
+ ref := "refs/jci/" + commit
+ if RefExists(ref) {
+ fmt.Printf("CI results already exist for %s\n", commit[:12])
+ return nil
+ }
}
repoRoot, err := GetRepoRoot()
@@ -64,13 +82,20 @@ func Run(args []string) error {
// Store results in git
msg := fmt.Sprintf("CI results for %s", commit[:12])
- if storeErr := StoreTree(outputDir, commit, msg); storeErr != nil {
+ if runID != "" {
+ msg = fmt.Sprintf("CI results for %s (run %s)", commit[:12], runID)
+ }
+ if storeErr := StoreTree(outputDir, commit, msg, runID); storeErr != nil {
return fmt.Errorf("failed to store CI results: %w", storeErr)
}
// Clean up the output directory after storing in git
os.RemoveAll(outputDir)
+ ref := "refs/jci/" + commit
+ if runID != "" {
+ ref = "refs/jci-runs/" + commit + "/" + runID
+ }
fmt.Printf("CI results stored at %s\n", ref)
if err != nil {
return fmt.Errorf("CI failed (results stored): %w", err)
@@ -197,3 +222,12 @@ func escapeHTML(s string) string {
}
return result
}
+
+// generateRunID creates a unique run identifier: <unix_timestamp>-<4_random_chars>
+func generateRunID() string {
+ timestamp := time.Now().Unix()
+ b := make([]byte, 2)
+ rand.Read(b)
+ randomSuffix := hex.EncodeToString(b)
+ return fmt.Sprintf("%d-%s", timestamp, randomSuffix)
+}
diff --git a/internal/jci/web.go b/internal/jci/web.go
@@ -19,12 +19,20 @@ type BranchInfo struct {
// CommitInfo holds commit data for the UI
type CommitInfo struct {
- Hash string `json:"hash"`
- ShortHash string `json:"shortHash"`
- Message string `json:"message"`
- HasCI bool `json:"hasCI"`
- CIStatus string `json:"ciStatus"` // "success", "failed", or ""
- CIPushed bool `json:"ciPushed"` // whether CI ref is pushed to remote
+ Hash string `json:"hash"`
+ ShortHash string `json:"shortHash"`
+ Message string `json:"message"`
+ HasCI bool `json:"hasCI"`
+ CIStatus string `json:"ciStatus"` // "success", "failed", or ""
+ CIPushed bool `json:"ciPushed"` // whether CI ref is pushed to remote
+ Runs []RunInfo `json:"runs"` // multiple runs (for cron)
+}
+
+// RunInfo holds info about a single CI run
+type RunInfo struct {
+ RunID string `json:"runId"`
+ Status string `json:"status"`
+ Ref string `json:"ref"`
}
// Web starts a web server to view CI results
@@ -110,7 +118,7 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) {
return nil, nil
}
- // Get local JCI refs
+ // Get local JCI refs (single-run)
jciRefs, _ := ListJCIRefs()
jciSet := make(map[string]bool)
for _, ref := range jciRefs {
@@ -118,6 +126,19 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) {
jciSet[commit] = true
}
+ // Get local JCI run refs (multi-run)
+ // Map: commit -> list of run refs
+ jciRuns := make(map[string][]string)
+ runRefs, _ := ListJCIRunRefs()
+ for _, ref := range runRefs {
+ // ref is refs/jci-runs/<commit>/<runid>
+ parts := strings.Split(strings.TrimPrefix(ref, "refs/jci-runs/"), "/")
+ if len(parts) >= 2 {
+ commit := parts[0]
+ jciRuns[commit] = append(jciRuns[commit], ref)
+ }
+ }
+
// Get remote JCI refs for CI push status
remoteCI := getRemoteJCIRefs("origin")
@@ -130,18 +151,38 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) {
hash := parts[0]
msg := parts[1]
+ hasCI := jciSet[hash] || len(jciRuns[hash]) > 0
commit := CommitInfo{
Hash: hash,
ShortHash: hash[:7],
Message: msg,
- HasCI: jciSet[hash],
- CIPushed: remoteCI[hash],
+ HasCI: hasCI,
+ CIPushed: remoteCI["refs/jci/"+hash],
}
- if commit.HasCI {
+ if jciSet[hash] {
commit.CIStatus = getCIStatus(hash)
}
+ // Add multi-run info
+ for _, runRef := range jciRuns[hash] {
+ parts := strings.Split(strings.TrimPrefix(runRef, "refs/jci-runs/"), "/")
+ if len(parts) >= 2 {
+ runID := parts[1]
+ status := getCIStatusFromRef(runRef)
+ commit.Runs = append(commit.Runs, RunInfo{
+ RunID: runID,
+ Status: status,
+ Ref: runRef,
+ })
+ }
+ }
+
+ // If no single-run status but has runs, use latest run status
+ if commit.CIStatus == "" && len(commit.Runs) > 0 {
+ commit.CIStatus = commit.Runs[len(commit.Runs)-1].Status
+ }
+
commits = append(commits, commit)
}
@@ -150,8 +191,11 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) {
// getCIStatus returns "success", "failed", or "running" based on status.txt
func getCIStatus(commit string) string {
- ref := "refs/jci/" + commit
-
+ return getCIStatusFromRef("refs/jci/" + commit)
+}
+
+// getCIStatusFromRef returns status from any ref
+func getCIStatusFromRef(ref string) string {
// Try to read status.txt (new format)
cmd := exec.Command("git", "show", ref+":status.txt")
out, err := cmd.Output()
@@ -190,20 +234,45 @@ type CommitDetail struct {
Date string `json:"date"`
Status string `json:"status"`
Files []string `json:"files"`
+ Ref string `json:"ref"` // The actual ref used
}
// serveCommitAPI returns commit details and file list
+// commit can be just <hash> or <hash>/<runid>
func serveCommitAPI(w http.ResponseWriter, commit string) {
- ref := "refs/jci/" + commit
+ var ref string
+ var actualCommit string
+
+ // Check if this is a run-specific request: <commit>/<runid>
+ if strings.Contains(commit, "/") {
+ parts := strings.SplitN(commit, "/", 2)
+ actualCommit = parts[0]
+ runID := parts[1]
+ ref = "refs/jci-runs/" + actualCommit + "/" + runID
+ } else {
+ actualCommit = commit
+ ref = "refs/jci/" + commit
+ // Check if single-run ref exists, otherwise try to find latest run
+ if !RefExists(ref) {
+ // Look for runs
+ runRefs, _ := ListJCIRunRefs()
+ for _, r := range runRefs {
+ if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") {
+ ref = r // Use the last one (they're sorted)
+ }
+ }
+ }
+ }
+
if !RefExists(ref) {
http.Error(w, "not found", 404)
return
}
// Get commit info
- author, _ := git("log", "-1", "--format=%an", commit)
- date, _ := git("log", "-1", "--format=%cr", commit)
- status := getCIStatus(commit)
+ author, _ := git("log", "-1", "--format=%an", actualCommit)
+ date, _ := git("log", "-1", "--format=%cr", actualCommit)
+ status := getCIStatusFromRef(ref)
// List files in the CI ref
filesOut, err := git("ls-tree", "--name-only", ref)
@@ -217,11 +286,12 @@ func serveCommitAPI(w http.ResponseWriter, commit string) {
}
detail := CommitDetail{
- Hash: commit,
+ Hash: actualCommit,
Author: author,
Date: date,
Status: status,
Files: files,
+ Ref: ref,
}
w.Header().Set("Content-Type", "application/json")
@@ -674,7 +744,39 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
}
func serveFromRef(w http.ResponseWriter, commit string, filePath string) {
- ref := "refs/jci/" + commit
+ var ref string
+
+ // Check if this is a run-specific request: <commit>/<runid>/<file>
+ // The commit param might be "abc123/1234567890-abcd"
+ if strings.Count(commit, "/") >= 1 {
+ // Could be <commit>/<runid> or just <commit>
+ parts := strings.SplitN(commit, "/", 2)
+ if len(parts) == 2 && len(parts[1]) > 0 {
+ // Check if parts[1] looks like a runID (timestamp-random)
+ if strings.Contains(parts[1], "-") && len(parts[1]) >= 10 {
+ ref = "refs/jci-runs/" + parts[0] + "/" + parts[1]
+ } else {
+ // It's probably <commit>/<file>, treat commit as just the hash
+ ref = "refs/jci/" + parts[0]
+ filePath = parts[1] + "/" + filePath
+ }
+ }
+ }
+
+ if ref == "" {
+ ref = "refs/jci/" + commit
+ }
+
+ if !RefExists(ref) {
+ // Try to find a run ref
+ runRefs, _ := ListJCIRunRefs()
+ for _, r := range runRefs {
+ if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") {
+ ref = r
+ }
+ }
+ }
+
if !RefExists(ref) {
http.Error(w, "CI results not found for commit: "+commit, 404)
return