commit fcb752edf737777c6e5f681f2fbf1bfc9cd9504a
parent bcacec63cfe080c4dab80ef59b77da3dc87b32c5
Author: Your Name <you@example.com>
Date: Sat, 28 Feb 2026 11:49:54 +0530
Merge branch 'cli_clean' into ttyfix
Diffstat:
8 files changed, 941 insertions(+), 345 deletions(-)
diff --git a/cmd/git-jci/main.go b/cmd/git-jci/main.go
@@ -1,10 +1,10 @@
package main
import (
- "fmt"
- "os"
+ "fmt"
+ "os"
- "github.com/exedev/git-jci/internal/jci"
+ "github.com/exedev/git-jci/internal/jci"
)
// version is set at build time via -ldflags "-X main.version=<version>".
@@ -12,57 +12,61 @@ import (
var version = "dev"
func main() {
- if len(os.Args) < 2 {
- printUsage()
- os.Exit(1)
- }
+ if len(os.Args) < 2 {
+ printUsage()
+ os.Exit(1)
+ }
- cmd := os.Args[1]
- args := os.Args[2:]
+ 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 "version", "--version", "-v":
- fmt.Println("git-jci version " + version)
- return
- case "help", "-h", "--help":
- printUsage()
- return
- default:
- fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
- printUsage()
- os.Exit(1)
- }
+ 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 "cron":
+ err = jci.Cron(args)
+ case "version", "--version", "-v":
+ fmt.Println("git-jci version " + version)
+ return
+ 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)
- }
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
}
func printUsage() {
- fmt.Printf(`git-jci %s - Local-first CI system stored in git
+ fmt.Printf(`git-jci %s - 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
- version Print the version and exit
+ run [--multi] 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
+ cron ls List cron jobs for this repository
+ cron sync Sync .jci/crontab with system cron
+ version Print the version and exit
-CI results are stored in refs/jci/<commit> namespace.
-`, version)
+CI results are stored in refs/jci/<commit>.
+With --multi, results are stored in refs/jci-runs/<commit>/<runid>.`, version)
}
diff --git a/internal/jci/cron.go b/internal/jci/cron.go
@@ -2,184 +2,297 @@ package jci
import (
"bufio"
- "crypto/sha1"
- "encoding/hex"
- "errors"
+ "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 errors.New("usage: git jci cron <ls|sync>")
- }
-
- repoRoot, err := GetRepoRoot()
- if err != nil {
- return err
+ return fmt.Errorf("usage: git jci cron <ls|sync>")
}
switch args[0] {
- case "ls", "list":
- return cronList(repoRoot)
+ case "ls":
+ return cronList()
case "sync":
- return cronSync(repoRoot)
+ return cronSync()
default:
- return fmt.Errorf("unknown cron subcommand: %s", args[0])
+ return fmt.Errorf("unknown cron command: %s (use ls or sync)", args[0])
}
}
-func cronList(repoRoot string) error {
- entries, err := LoadCronEntries(repoRoot)
+// cronList shows current cron jobs from .jci/crontab and system cron
+func cronList() error {
+ repoRoot, err := GetRepoRoot()
if err != nil {
- return err
+ return fmt.Errorf("not in a git repository: %w", err)
}
- if len(entries) == 0 {
- fmt.Println("No cron jobs defined. Create .jci/crontab to add jobs.")
- return nil
+ 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("%-10s %-17s %-10s %s\n", "ID", "SCHEDULE", "TYPE", "COMMAND")
- for _, entry := range entries {
- job := newCronJob(entry, repoRoot)
- fmt.Printf("%-10s %-17s %-10s %s\n", job.ID[:8], job.Schedule, job.Type, job.Command)
- if job.Type == CronJobBinary {
- if _, err := os.Stat(job.BinaryPath); os.IsNotExist(err) {
- fmt.Printf(" warning: binary %s does not exist\n", job.BinaryPath)
+ 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
}
-func cronSync(repoRoot string) error {
- entries, err := LoadCronEntries(repoRoot)
+// cronSync synchronizes .jci/crontab with system cron
+func cronSync() error {
+ repoRoot, err := GetRepoRoot()
if err != nil {
- return err
+ return fmt.Errorf("not in a git repository: %w", err)
}
- if len(entries) == 0 {
- return errors.New("no cron jobs defined in .jci/crontab")
- }
+ repoID := getRepoID(repoRoot)
+ marker := cronMarkerPrefix + repoID
- var jobs []CronJob
- for _, entry := range entries {
- jobs = append(jobs, newCronJob(entry, repoRoot))
+ // 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)
+ }
}
- block, err := buildCronBlock(repoRoot, jobs)
+ // Get current system crontab
+ currentCron, err := getCurrentCrontab()
if err != nil {
- return err
+ return fmt.Errorf("failed to read current crontab: %w", err)
}
- existing, err := readCrontab()
- if err != nil {
- return 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)
+ }
}
- updated := applyCronBlock(existing, block, repoRoot)
- if err := installCrontab(updated); err != nil {
- return err
+ // Find git-jci binary path
+ jciBinary, err := findJCIBinary()
+ if err != nil {
+ return fmt.Errorf("could not find git-jci binary: %w", err)
}
- fmt.Printf("Synced %d cron job(s).\n", len(jobs))
- return nil
-}
+ // 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", jciBinary)
-func newCronJob(entry CronEntry, repoRoot string) CronJob {
- jobType, binPath, binArgs := classifyCronCommand(entry.Command, repoRoot)
- id := cronJobID(entry.Schedule, entry.Command)
- logPath := filepath.Join(repoRoot, ".jci", fmt.Sprintf("cron-%s.log", id[:8]))
- return CronJob{
- ID: id,
- Schedule: entry.Schedule,
- Command: entry.Command,
- Type: jobType,
- BinaryPath: binPath,
- BinaryArgs: binArgs,
- Line: entry.Line,
- CronLog: logPath,
- }
-}
+ comment := e.Name
+ if comment == "" {
+ comment = "jci"
+ }
-func buildCronBlock(repoRoot string, jobs []CronJob) (string, error) {
- if err := os.MkdirAll(filepath.Join(repoRoot, ".jci"), 0755); err != nil {
- return "", fmt.Errorf("failed to create .jci directory: %w", err)
+ line := fmt.Sprintf("%s %s %s [%s]", e.Schedule, cmd, marker, comment)
+ newLines = append(newLines, line)
}
- blockID := cronBlockMarker(repoRoot)
- var lines []string
- lines = append(lines, fmt.Sprintf("# BEGIN %s", blockID))
+ // 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"
- for _, job := range jobs {
- line := fmt.Sprintf("%s %s", job.Schedule, job.shellCommand(repoRoot))
- lines = append(lines, line)
+ if err := installCrontab(newCron); err != nil {
+ return fmt.Errorf("failed to install crontab: %w", err)
}
- lines = append(lines, fmt.Sprintf("# END %s", blockID))
- return strings.Join(lines, "\n"), nil
-}
+ 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)
+ }
-func cronBlockMarker(repoRoot string) string {
- hash := sha1.Sum([]byte(repoRoot))
- return fmt.Sprintf("git-jci %s %s", repoRoot, hex.EncodeToString(hash[:8]))
+ return nil
}
-func readCrontab() (string, error) {
- if _, err := exec.LookPath("crontab"); err != nil {
- return "", fmt.Errorf("crontab command not found: %w", err)
- }
-
- cmd := exec.Command("crontab", "-l")
- out, err := cmd.CombinedOutput()
+// 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 {
- if strings.Contains(string(out), "no crontab for") {
- return "", nil
- }
- return "", fmt.Errorf("crontab -l: %v", err)
+ return nil, err
}
- return string(out), nil
-}
+ defer f.Close()
-func applyCronBlock(existing, block, repoRoot string) string {
- begin := fmt.Sprintf("# BEGIN %s", cronBlockMarker(repoRoot))
- end := fmt.Sprintf("# END %s", cronBlockMarker(repoRoot))
+ 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*(.*)`)
- var result []string
- scanner := bufio.NewScanner(strings.NewReader(existing))
- skip := false
for scanner.Scan() {
- line := scanner.Text()
- trimmed := strings.TrimSpace(line)
- if trimmed == begin {
- skip = true
+ line := strings.TrimSpace(scanner.Text())
+
+ // Skip empty lines and comments
+ if line == "" || strings.HasPrefix(line, "#") {
continue
}
- if trimmed == end {
- skip = false
- continue
+
+ matches := scheduleRe.FindStringSubmatch(line)
+ if matches == nil {
+ continue // Invalid line, skip
+ }
+
+ entry := CronEntry{
+ Schedule: matches[1],
}
- if !skip {
- result = append(result, line)
+
+ // 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)
}
- if len(result) != 0 && strings.TrimSpace(result[len(result)-1]) != "" {
- result = append(result, "")
+ 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
}
- result = append(result, block)
- return strings.Join(result, "\n") + "\n"
+ return string(out), nil
}
+// installCrontab installs a new crontab
func installCrontab(content string) error {
cmd := exec.Command("crontab", "-")
cmd.Stdin = strings.NewReader(content)
- if out, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("failed to install crontab: %v (%s)", err, string(out))
+ 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
@@ -37,8 +37,8 @@ func RefExists(ref string) bool {
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 {
+// StoreTree stores a directory as a tree object and creates a commit under refs/jci-runs/<commit>/<runID>
+func StoreTree(dir string, commit string, message string, runID string) error {
repoRoot, err := GetRepoRoot()
if err != nil {
return err
@@ -63,8 +63,8 @@ func StoreTree(dir string, commit string, message string) error {
}
commitID := strings.TrimSpace(string(commitOut))
- // Update ref
- ref := "refs/jci/" + commit
+ // Update ref: refs/jci-runs/<commit>/<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 +142,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
@@ -2,6 +2,7 @@ package jci
import (
"fmt"
+ "strings"
)
// Push pushes CI results to remote
@@ -11,24 +12,73 @@ func Push(args []string) error {
remote = args[0]
}
- refs, err := ListJCIRefs()
+ // Get all local JCI refs
+ localRefs, err := ListAllJCIRefs()
if err != nil {
return err
}
- if len(refs) == 0 {
+ if len(localRefs) == 0 {
fmt.Println("No CI results to push")
return nil
}
- fmt.Printf("Pushing %d CI result(s) to %s...\n", len(refs), remote)
+ // Get remote refs to find what's already pushed
+ remoteRefs := getRemoteJCIRefs(remote)
- // Push all refs/jci/* to remote
- _, err = git("push", remote, "refs/jci/*:refs/jci/*")
- if err != nil {
- return err
+ // Find refs that need to be pushed
+ var toPush []string
+ for _, ref := range localRefs {
+ if !remoteRefs[ref] {
+ toPush = append(toPush, ref)
+ }
+ }
+
+ if len(toPush) == 0 {
+ fmt.Println("All CI results already pushed")
+ return nil
+ }
+
+ fmt.Printf("Pushing %d new CI result(s) to %s...\n", len(toPush), remote)
+
+ // Push each ref individually
+ for _, ref := range toPush {
+ _, err = git("push", remote, ref+":"+ref)
+ if err != nil {
+ return fmt.Errorf("failed to push %s: %w", ref, err)
+ }
+ fmt.Printf(" %s\n", ref)
}
fmt.Println("Done")
return nil
}
+
+// 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 && out != "" {
+ for _, line := range strings.Split(out, "\n") {
+ parts := strings.Fields(line)
+ if len(parts) >= 2 {
+ remoteCI[parts[1]] = 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,6 +11,7 @@ import (
)
// Run executes CI for the current commit
+// Each run gets a unique ID (timestamp+random suffix) stored in refs/jci-runs/<commit>/<runid>
func Run(args []string) error {
// Get current commit
commit, err := GetCurrentCommit()
@@ -18,12 +21,9 @@ 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 unique run ID
+ runID := generateRunID()
+ fmt.Printf("Run ID: %s\n", runID)
repoRoot, err := GetRepoRoot()
if err != nil {
@@ -42,24 +42,36 @@ func Run(args []string) error {
return fmt.Errorf("failed to create output dir: %w", err)
}
+ // Set initial status to "running"
+ statusFile := filepath.Join(outputDir, "status.txt")
+ os.WriteFile(statusFile, []byte("running"), 0644)
+
// Run CI
err = runCI(repoRoot, outputDir, commit)
// Continue even if CI fails - we still want to store the results
+ // Update status based on result
+ if err != nil {
+ os.WriteFile(statusFile, []byte("err"), 0644)
+ } else {
+ os.WriteFile(statusFile, []byte("ok"), 0644)
+ }
+
// 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 {
+ 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-runs/" + commit + "/" + runID
fmt.Printf("CI results stored at %s\n", ref)
if err != nil {
return fmt.Errorf("CI failed (results stored): %w", err)
@@ -121,11 +133,13 @@ func runCI(repoRoot string, outputDir string, commit string) error {
func generateIndexHTML(outputDir string, commit string, ciErr error) error {
commitMsg, _ := git("log", "-1", "--format=%s", commit)
- status := "success"
statusIcon := "✓ PASSED"
+ statusColor := "#1a7f37"
+ statusBg := "#dafbe1"
if ciErr != nil {
- status = "failed"
statusIcon = "✗ FAILED"
+ statusColor = "#cf222e"
+ statusBg = "#ffebe9"
}
// Read output for standalone view
@@ -141,22 +155,25 @@ func generateIndexHTML(outputDir string, commit string, ciErr error) error {
<meta charset="utf-8">
<title>%s %s</title>
<style>
- body { font-family: monospace; font-size: 12px; background: #1a1a1a; color: #e0e0e0; padding: 8px; }
- .header { margin-bottom: 8px; }
- .%s { color: %s; font-weight: bold; }
- pre { white-space: pre-wrap; }
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; font-size: 13px; background: #f5f5f5; color: #24292f; padding: 16px; }
+ .header { margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 8px; border: 1px solid #d0d7de; }
+ .status { display: inline-block; padding: 4px 10px; border-radius: 16px; font-weight: 600; font-size: 12px; background: %s; color: %s; }
+ .commit-info { margin-top: 8px; color: #57606a; font-size: 12px; }
+ .commit-hash { color: #0969da; font-family: monospace; }
+ pre { white-space: pre-wrap; background: #fff; padding: 16px; border-radius: 8px; border: 1px solid #d0d7de; font-family: "Monaco", "Menlo", monospace; font-size: 12px; line-height: 1.5; }
</style>
</head>
<body>
<div class="header">
- <span class="%s">%s</span> %s %s
+ <span class="status">%s</span>
+ <div class="commit-info"><span class="commit-hash">%s</span> %s</div>
</div>
<pre>%s</pre>
</body>
</html>
`, commit[:7], escapeHTML(commitMsg),
- status, map[string]string{"success": "#3fb950", "failed": "#f85149"}[status],
- status, statusIcon, commit[:7], escapeHTML(commitMsg),
+ statusBg, statusColor,
+ statusIcon, commit[:7], escapeHTML(commitMsg),
escapeHTML(outputContent))
indexPath := filepath.Join(outputDir, "index.html")
@@ -181,3 +198,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
@@ -52,12 +60,6 @@ func Web(args []string) error {
func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) {
path := r.URL.Path
- // Root or /jci/... without file: show main SPA
- if path == "/" || (strings.HasPrefix(path, "/jci/") && !strings.Contains(strings.TrimPrefix(path, "/jci/"), ".")) {
- showMainPage(w, r)
- return
- }
-
// API endpoint for branch data
if path == "/api/branches" {
serveBranchesAPI(w)
@@ -71,19 +73,27 @@ func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) {
return
}
- // /jci/<commit>/<file> - 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 := ""
- if len(parts) > 1 {
- filePath = parts[1]
- }
- if filePath == "" {
- showMainPage(w, r)
+ // /jci/<commit>/<file>/raw - serve raw file
+ // Also handles /jci/<commit>/<runid>/<file>/raw
+ if strings.HasPrefix(path, "/jci/") && strings.HasSuffix(path, "/raw") {
+ trimmed := strings.TrimPrefix(path, "/jci/")
+ trimmed = strings.TrimSuffix(trimmed, "/raw")
+ // trimmed is now: <commit>/<file> or <commit>/<runid>/<file>
+ parts := strings.SplitN(trimmed, "/", 3)
+ if len(parts) == 2 && parts[1] != "" {
+ // <commit>/<file>
+ serveFromRef(w, parts[0], "", parts[1])
+ return
+ } else if len(parts) == 3 && parts[2] != "" {
+ // <commit>/<runid>/<file>
+ serveFromRef(w, parts[0], parts[1], parts[2])
return
}
- serveFromRef(w, commit, filePath)
+ }
+
+ // Root or /jci/... - show main SPA (UI handles routing)
+ if path == "/" || strings.HasPrefix(path, "/jci/") {
+ showMainPage(w, r)
return
}
@@ -103,29 +113,6 @@ func getLocalBranches() ([]string, error) {
}
// getRemoteJCIRefs returns a set of commits that have CI refs pushed to remote
-func getRemoteJCIRefs(remote string) map[string]bool {
- remoteCI := make(map[string]bool)
-
- // Get remote JCI refs
- out, err := git("ls-remote", "--refs", remote, "refs/jci/*")
- if err != nil {
- return remoteCI
- }
- if out == "" {
- return remoteCI
- }
-
- for _, line := range strings.Split(out, "\n") {
- parts := strings.Fields(line)
- if len(parts) >= 2 {
- // refs/jci/<commit> -> <commit>
- ref := parts[1]
- commit := strings.TrimPrefix(ref, "refs/jci/")
- remoteCI[commit] = true
- }
- }
- return remoteCI
-}
// getBranchCommits returns recent commits for a branch
func getBranchCommits(branch string, limit int) ([]CommitInfo, error) {
@@ -138,7 +125,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 {
@@ -146,6 +133,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")
@@ -158,39 +158,77 @@ 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)
}
return commits, nil
}
-// getCIStatus returns "success" or "failed" based on CI results
+// getCIStatus returns "success", "failed", or "running" based on status.txt
func getCIStatus(commit string) string {
- // Try to read the index.html and look for status
- ref := "refs/jci/" + commit
- cmd := exec.Command("git", "show", ref+":index.html")
+ 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()
+ if err == nil {
+ status := strings.TrimSpace(string(out))
+ switch status {
+ case "ok":
+ return "success"
+ case "err":
+ return "failed"
+ case "running":
+ return "running"
+ }
+ }
+
+ // Fallback: parse index.html for old results
+ cmd = exec.Command("git", "show", ref+":index.html")
+ out, err = cmd.Output()
if err != nil {
return ""
}
-
content := string(out)
- if strings.Contains(content, "class=\"status success\"") {
+ if strings.Contains(content, "PASSED") || strings.Contains(content, "SUCCESS") {
return "success"
}
- if strings.Contains(content, "class=\"status failed\"") {
+ if strings.Contains(content, "FAILED") {
return "failed"
}
return ""
@@ -198,25 +236,75 @@ func getCIStatus(commit string) string {
// CommitDetail holds detailed commit info for the API
type CommitDetail struct {
- Hash string `json:"hash"`
- Author string `json:"author"`
- Date string `json:"date"`
- Status string `json:"status"`
- Files []string `json:"files"`
+ Hash string `json:"hash"`
+ Author string `json:"author"`
+ Date string `json:"date"`
+ Status string `json:"status"`
+ Files []string `json:"files"`
+ Ref string `json:"ref"` // The actual ref used
+ RunID string `json:"runId"` // Current run ID
+ Runs []RunInfo `json:"runs"` // All runs for this commit
}
// 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
+ var currentRunID string
+
+ // Check if this is a run-specific request: <commit>/<runid>
+ if strings.Contains(commit, "/") {
+ parts := strings.SplitN(commit, "/", 2)
+ actualCommit = parts[0]
+ currentRunID = parts[1]
+ ref = "refs/jci-runs/" + actualCommit + "/" + currentRunID
+ } 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 - get the latest one
+ runRefs, _ := ListJCIRunRefs()
+ for _, r := range runRefs {
+ if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") {
+ ref = r // Use the last one (they're sorted)
+ parts := strings.Split(r, "/")
+ if len(parts) >= 4 {
+ currentRunID = parts[3]
+ }
+ }
+ }
+ }
+ }
+
if !RefExists(ref) {
http.Error(w, "not found", 404)
return
}
+ // Get all runs for this commit
+ var runs []RunInfo
+ runRefs, _ := ListJCIRunRefs()
+ for _, r := range runRefs {
+ if strings.HasPrefix(r, "refs/jci-runs/"+actualCommit+"/") {
+ parts := strings.Split(r, "/")
+ if len(parts) >= 4 {
+ runID := parts[3]
+ status := getCIStatusFromRef(r)
+ runs = append(runs, RunInfo{
+ RunID: runID,
+ Status: status,
+ Ref: r,
+ })
+ }
+ }
+ }
+
// 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)
@@ -230,11 +318,14 @@ func serveCommitAPI(w http.ResponseWriter, commit string) {
}
detail := CommitDetail{
- Hash: commit,
+ Hash: actualCommit,
Author: author,
Date: date,
Status: status,
Files: files,
+ Ref: ref,
+ RunID: currentRunID,
+ Runs: runs,
}
w.Header().Set("Content-Type", "application/json")
@@ -279,41 +370,42 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
font-size: 12px;
- background: #1a1a1a;
- color: #e0e0e0;
+ background: #f5f5f5;
+ color: #333;
display: flex;
height: 100vh;
overflow: hidden;
}
- a { color: #58a6ff; text-decoration: none; }
+ a { color: #0969da; text-decoration: none; }
a:hover { text-decoration: underline; }
/* Left panel - commits */
.commits-panel {
- width: 240px;
- background: #1e1e1e;
- border-right: 1px solid #333;
+ width: 280px;
+ background: #fff;
+ border-right: 1px solid #d0d7de;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.panel-header {
- padding: 6px 8px;
- background: #252525;
- border-bottom: 1px solid #333;
+ padding: 8px 10px;
+ background: #f6f8fa;
+ border-bottom: 1px solid #d0d7de;
display: flex;
align-items: center;
- gap: 6px;
+ gap: 8px;
}
- .panel-header h1 { font-size: 12px; font-weight: 600; color: #888; }
+ .panel-header h1 { font-size: 13px; font-weight: 600; color: #24292f; }
+ .logo { height: 24px; width: auto; }
.branch-selector {
flex: 1;
- padding: 2px 4px;
- font-size: 11px;
- border: 1px solid #444;
- border-radius: 3px;
- background: #2a2a2a;
- color: #fff;
+ padding: 4px 8px;
+ font-size: 12px;
+ border: 1px solid #d0d7de;
+ border-radius: 6px;
+ background: #fff;
+ color: #24292f;
}
.commit-list {
list-style: none;
@@ -321,62 +413,95 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
flex: 1;
}
.commit-item {
- padding: 3px 6px;
+ padding: 6px 10px;
cursor: pointer;
display: flex;
align-items: center;
- gap: 4px;
- border-bottom: 1px solid #252525;
+ gap: 8px;
+ border-bottom: 1px solid #eaeef2;
}
- .commit-item:hover { background: #2a2a2a; }
- .commit-item.selected { background: #2d4a3e; }
+ .commit-item:hover { background: #f6f8fa; }
+ .commit-item.selected { background: #ddf4ff; }
.commit-item.no-ci { opacity: 0.5; }
- .ci-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
- .ci-dot.success { background: #3fb950; }
- .ci-dot.failed { background: #f85149; }
- .ci-dot.none { background: #484f58; }
- .commit-hash { font-size: 10px; color: #58a6ff; flex-shrink: 0; }
- .commit-msg { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #888; font-size: 11px; }
- .ci-push-badge { font-size: 8px; color: #666; }
- .ci-push-badge.pushed { color: #3fb950; }
+
+ /* Status indicator */
+ .status-badge {
+ font-size: 10px;
+ font-weight: 600;
+ padding: 2px 6px;
+ border-radius: 12px;
+ flex-shrink: 0;
+ }
+ .status-badge.success { background: #dafbe1; color: #1a7f37; }
+ .status-badge.failed { background: #ffebe9; color: #cf222e; }
+ .status-badge.running { background: #fff8c5; color: #9a6700; }
+ .status-badge.none { background: #eaeef2; color: #656d76; }
+
+ .commit-hash { font-size: 11px; color: #0969da; flex-shrink: 0; font-family: monospace; }
+ .commit-msg { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #57606a; font-size: 12px; }
+
+ /* Push status badge */
+ .push-badge {
+ font-size: 9px;
+ font-weight: 600;
+ padding: 1px 5px;
+ border-radius: 10px;
+ flex-shrink: 0;
+ }
+ .push-badge.pushed { background: #ddf4ff; color: #0969da; }
+ .push-badge.local { background: #fff8c5; color: #9a6700; }
/* Middle panel - files */
.files-panel {
- width: 180px;
- background: #1e1e1e;
- border-right: 1px solid #333;
+ width: 200px;
+ background: #fff;
+ border-right: 1px solid #d0d7de;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.files-panel.hidden { display: none; }
.commit-info {
- padding: 6px 8px;
- background: #252525;
- border-bottom: 1px solid #333;
- font-size: 11px;
+ padding: 10px 12px;
+ background: #f6f8fa;
+ border-bottom: 1px solid #d0d7de;
+ font-size: 12px;
+ }
+ .commit-info .status-line {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
}
- .commit-info .status { font-weight: 600; }
- .commit-info .status.success { color: #3fb950; }
- .commit-info .status.failed { color: #f85149; }
- .commit-info .hash { color: #58a6ff; }
- .commit-info .meta { color: #666; margin-top: 2px; }
+ .commit-info .status-icon { font-size: 14px; font-weight: bold; }
+ .commit-info .status-icon.success { color: #1a7f37; }
+ .commit-info .status-icon.failed { color: #cf222e; }
+ .commit-info .status-icon.running { color: #9a6700; }
+ .commit-info .hash { color: #0969da; font-family: monospace; }
+ .commit-info .meta { color: #656d76; margin-top: 4px; font-size: 11px; }
+ .run-selector { margin-top: 8px; }
+ .run-selector select { width: 100%; padding: 4px 6px; font-size: 11px; border: 1px solid #d0d7de; border-radius: 4px; background: #fff; }
+ .run-nav { display: flex; gap: 4px; margin-top: 6px; }
+ .run-nav button { flex: 1; padding: 4px 8px; font-size: 10px; border: 1px solid #d0d7de; border-radius: 4px; background: #f6f8fa; cursor: pointer; }
+ .run-nav button:hover { background: #eaeef2; }
+ .run-nav button:disabled { opacity: 0.5; cursor: not-allowed; }
.file-list {
list-style: none;
overflow-y: auto;
flex: 1;
}
.file-item {
- padding: 3px 8px;
+ padding: 6px 12px;
cursor: pointer;
- border-bottom: 1px solid #252525;
+ border-bottom: 1px solid #eaeef2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- font-size: 11px;
+ font-size: 12px;
+ color: #24292f;
}
- .file-item:hover { background: #2a2a2a; }
- .file-item.selected { background: #2d4a3e; }
+ .file-item:hover { background: #f6f8fa; }
+ .file-item.selected { background: #ddf4ff; }
/* Right panel - content */
.content-panel {
@@ -384,26 +509,44 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
display: flex;
flex-direction: column;
min-width: 0;
- background: #1a1a1a;
+ background: #fff;
}
.content-header {
- padding: 4px 8px;
- background: #252525;
- border-bottom: 1px solid #333;
+ padding: 6px 12px;
+ background: #f6f8fa;
+ border-bottom: 1px solid #d0d7de;
+ font-size: 12px;
+ color: #57606a;
+ font-family: monospace;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+ .content-header .filename { flex: 1; }
+ .download-btn {
+ padding: 3px 8px;
font-size: 11px;
- color: #888;
+ background: #fff;
+ border: 1px solid #d0d7de;
+ border-radius: 4px;
+ color: #24292f;
+ cursor: pointer;
+ text-decoration: none;
}
+ .download-btn:hover { background: #f6f8fa; text-decoration: none; }
.content-body {
flex: 1;
overflow: auto;
+ background: #fff;
}
.content-body pre {
- padding: 8px;
- font-family: "Monaco", "Menlo", monospace;
- font-size: 11px;
- line-height: 1.4;
+ padding: 12px;
+ font-family: "Monaco", "Menlo", "Consolas", monospace;
+ font-size: 12px;
+ line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
+ color: #24292f;
}
.content-body iframe {
width: 100%;
@@ -416,19 +559,20 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
align-items: center;
justify-content: center;
height: 100%;
- color: #666;
+ color: #656d76;
+ font-size: 13px;
}
@media (max-width: 700px) {
- .commits-panel { width: 180px; }
- .files-panel { width: 140px; }
+ .commits-panel { width: 200px; }
+ .files-panel { width: 150px; }
}
</style>
</head>
<body>
<div class="commits-panel">
<div class="panel-header">
- <h1>JCI</h1>
+ <img class="logo" src="data:image/png;base64,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" alt="JCI">
<select class="branch-selector" id="branchSelect"></select>
</div>
<ul class="commit-list" id="commitList"></ul>
@@ -445,7 +589,7 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
</div>
<script>
- let branches = [], currentCommit = null, currentFiles = [], currentFile = null;
+ let branches = [], currentCommit = null, currentFiles = [], currentFile = null, currentRuns = [], currentRunId = null;
async function loadBranches() {
const res = await fetch('/api/branches');
@@ -455,25 +599,40 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
const def = branches.find(b => b.name === 'main') || branches[0];
if (def) { select.value = def.name; showBranch(def.name); }
- // Check URL for initial commit
+ // Check URL for initial commit and file
const m = location.pathname.match(/^\/jci\/([a-f0-9]+)/);
if (m) selectCommitByHash(m[1]);
}
+ function getStatusLabel(status) {
+ switch(status) {
+ case 'success': return '✓ ok';
+ case 'failed': return '✗ err';
+ case 'running': return '⋯ run';
+ default: return '—';
+ }
+ }
+
function showBranch(name) {
const branch = branches.find(b => b.name === name);
if (!branch) return;
const list = document.getElementById('commitList');
list.innerHTML = (branch.commits || []).map(c => {
- const status = c.hasCI ? c.ciStatus : 'none';
- const pushIcon = c.hasCI ? (c.ciPushed ? '↑' : '○') : '';
- const pushClass = c.ciPushed ? 'pushed' : '';
+ const status = c.hasCI ? (c.ciStatus || 'none') : 'none';
const noCiClass = c.hasCI ? '' : 'no-ci';
+ let pushBadge = '';
+ if (c.hasCI) {
+ if (c.ciPushed) {
+ pushBadge = '<span class="push-badge pushed">pushed</span>';
+ } else {
+ pushBadge = '<span class="push-badge local">local</span>';
+ }
+ }
return '<li class="commit-item ' + noCiClass + '" data-hash="' + c.hash + '" data-hasci="' + c.hasCI + '">' +
- '<span class="ci-dot ' + status + '"></span>' +
+ '<span class="status-badge ' + status + '">' + getStatusLabel(status) + '</span>' +
'<span class="commit-hash">' + c.shortHash + '</span>' +
'<span class="commit-msg">' + escapeHtml(c.message) + '</span>' +
- '<span class="ci-push-badge ' + pushClass + '">' + pushIcon + '</span></li>';
+ pushBadge + '</li>';
}).join('');
list.querySelectorAll('.commit-item').forEach(el => {
el.onclick = () => selectCommit(el.dataset.hash, el.dataset.hasci === 'true');
@@ -490,6 +649,7 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
async function selectCommit(hash, hasCI) {
currentCommit = hash;
+ currentFile = null;
document.querySelectorAll('.commit-item').forEach(el =>
el.classList.toggle('selected', el.dataset.hash === hash)
);
@@ -500,24 +660,83 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
if (!hasCI) {
filesPanel.classList.add('hidden');
- contentHeader.textContent = '';
+ contentHeader.innerHTML = '';
contentBody.innerHTML = '<div class="empty-state">No CI results. Run: git jci run</div>';
history.pushState(null, '', '/');
return;
}
filesPanel.classList.remove('hidden');
- history.pushState(null, '', '/jci/' + hash + '/');
+ // Only update URL to commit if not already on a file URL for this commit
+ if (!location.pathname.startsWith('/jci/' + hash)) {
+ history.pushState(null, '', '/jci/' + hash);
+ }
// Load commit info and files
try {
const infoRes = await fetch('/api/commit/' + hash);
const info = await infoRes.json();
+ let statusIcon = '?';
+ let statusClass = '';
+ if (info.status === 'success') { statusIcon = '✓'; statusClass = 'success'; }
+ else if (info.status === 'failed') { statusIcon = '✗'; statusClass = 'failed'; }
+ else if (info.status === 'running') { statusIcon = '⋯'; statusClass = 'running'; }
+
+ // Store runs info
+ currentRuns = info.runs || [];
+ currentRunId = info.runId || null;
+
+ // Build run selector if multiple runs
+ let runSelectorHtml = '';
+ if (currentRuns.length > 1) {
+ const runIdx = currentRuns.findIndex(r => r.runId === currentRunId);
+ runSelectorHtml = '<div class="run-selector">' +
+ '<select id="runSelect">' +
+ currentRuns.map((r, i) => {
+ const ts = parseInt(r.runId.split('-')[0]) * 1000;
+ const date = new Date(ts).toLocaleString();
+ const statusEmoji = r.status === 'success' ? '✓' : r.status === 'failed' ? '✗' : '?';
+ const selected = r.runId === currentRunId ? ' selected' : '';
+ return '<option value="' + r.runId + '"' + selected + '>' + statusEmoji + ' Run ' + (i+1) + ' - ' + date + '</option>';
+ }).join('') +
+ '</select></div>' +
+ '<div class="run-nav">' +
+ '<button id="prevRun"' + (runIdx <= 0 ? ' disabled' : '') + '>← Prev</button>' +
+ '<button id="nextRun"' + (runIdx >= currentRuns.length - 1 ? ' disabled' : '') + '>Next →</button>' +
+ '</div>';
+ } else if (currentRuns.length === 1) {
+ const ts = parseInt(currentRuns[0].runId.split('-')[0]) * 1000;
+ const date = new Date(ts).toLocaleString();
+ runSelectorHtml = '<div class="meta">Run: ' + date + '</div>';
+ }
+
document.getElementById('commitInfo').innerHTML =
- '<div><span class="status ' + info.status + '">' + (info.status === 'success' ? '✓' : '✗') + '</span> ' +
+ '<div class="status-line">' +
+ '<span class="status-icon ' + statusClass + '">' + statusIcon + '</span>' +
'<span class="hash">' + hash.slice(0,7) + '</span></div>' +
- '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>';
+ '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>' +
+ runSelectorHtml;
+
+ // Set up run selector events
+ const runSelect = document.getElementById('runSelect');
+ if (runSelect) {
+ runSelect.onchange = (e) => selectRun(hash, e.target.value);
+ }
+ const prevBtn = document.getElementById('prevRun');
+ const nextBtn = document.getElementById('nextRun');
+ if (prevBtn) {
+ prevBtn.onclick = () => {
+ const idx = currentRuns.findIndex(r => r.runId === currentRunId);
+ if (idx > 0) selectRun(hash, currentRuns[idx-1].runId);
+ };
+ }
+ if (nextBtn) {
+ nextBtn.onclick = () => {
+ const idx = currentRuns.findIndex(r => r.runId === currentRunId);
+ if (idx < currentRuns.length - 1) selectRun(hash, currentRuns[idx+1].runId);
+ };
+ }
currentFiles = info.files || [];
const fileList = document.getElementById('fileList');
@@ -528,15 +747,20 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
el.onclick = () => loadFile(el.dataset.file);
});
- // Load default file
- const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0];
- if (defaultFile) loadFile(defaultFile);
+ // Check URL for initial file, otherwise load default
+ const urlMatch = location.pathname.match(/^\/jci\/[a-f0-9]+\/(.+)$/);
+ if (urlMatch && urlMatch[1]) {
+ loadFile(urlMatch[1], true);
+ } else {
+ const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0];
+ if (defaultFile) loadFile(defaultFile, true);
+ }
} catch (e) {
contentBody.innerHTML = '<div class="empty-state">Failed to load</div>';
}
}
- function loadFile(name) {
+ function loadFile(name, skipHistory) {
currentFile = name;
document.querySelectorAll('.file-item').forEach(el =>
el.classList.toggle('selected', el.dataset.file === name)
@@ -544,23 +768,113 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
const contentHeader = document.getElementById('contentHeader');
const contentBody = document.getElementById('contentBody');
- contentHeader.textContent = name;
+ // Include runId in URL if available
+ const commitPath = currentRunId ? currentCommit + '/' + currentRunId : currentCommit;
+ const rawUrl = '/jci/' + commitPath + '/' + name + '/raw';
+
+ contentHeader.innerHTML = '<span class="filename">' + escapeHtml(name) + '</span>' +
+ '<a class="download-btn" href="' + rawUrl + '" target="_blank">↓ Download</a>';
- history.pushState(null, '', '/jci/' + currentCommit + '/' + name);
+ if (!skipHistory) {
+ history.pushState(null, '', '/jci/' + commitPath + '/' + name);
+ }
- const ext = name.split('.').pop().toLowerCase();
+ const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
const textExts = ['txt', 'log', 'sh', 'go', 'py', 'js', 'json', 'yaml', 'yml', 'md', 'css', 'xml', 'toml', 'ini', 'conf'];
- const url = '/jci/' + currentCommit + '/' + name;
+ const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'];
if (ext === 'html' || ext === 'htm') {
- contentBody.innerHTML = '<iframe src="' + url + '"></iframe>';
- } else if (textExts.includes(ext) || !name.includes('.')) {
- fetch(url).then(r => r.text()).then(text => {
+ contentBody.innerHTML = '<iframe src="' + rawUrl + '"></iframe>';
+ } else if (imageExts.includes(ext)) {
+ contentBody.innerHTML = '<div style="padding: 12px; text-align: center;"><img src="' + rawUrl + '" style="max-width: 100%; max-height: 100%;"></div>';
+ } else if (textExts.includes(ext)) {
+ fetch(rawUrl).then(r => r.text()).then(text => {
contentBody.innerHTML = '<pre>' + escapeHtml(text) + '</pre>';
});
} else {
- contentBody.innerHTML = '<div class="empty-state"><a href="' + url + '" download>Download ' + name + '</a></div>';
+ contentBody.innerHTML = '<div class="empty-state">Binary file. <a href="' + rawUrl + '" target="_blank">Download ' + escapeHtml(name) + '</a></div>';
+ }
+ }
+
+ async function selectRun(hash, runId) {
+ currentRunId = runId;
+ currentFile = null;
+ // Fetch the specific run
+ const infoRes = await fetch('/api/commit/' + hash + '/' + runId);
+ const info = await infoRes.json();
+
+ let statusIcon = '?';
+ let statusClass = '';
+ if (info.status === 'success') { statusIcon = '✓'; statusClass = 'success'; }
+ else if (info.status === 'failed') { statusIcon = '✗'; statusClass = 'failed'; }
+ else if (info.status === 'running') { statusIcon = '⋯'; statusClass = 'running'; }
+
+ // Update runs from response
+ currentRuns = info.runs || [];
+
+ // Build run selector
+ let runSelectorHtml = '';
+ if (currentRuns.length > 1) {
+ const runIdx = currentRuns.findIndex(r => r.runId === currentRunId);
+ runSelectorHtml = '<div class="run-selector">' +
+ '<select id="runSelect">' +
+ currentRuns.map((r, i) => {
+ const ts = parseInt(r.runId.split('-')[0]) * 1000;
+ const date = new Date(ts).toLocaleString();
+ const statusEmoji = r.status === 'success' ? '✓' : r.status === 'failed' ? '✗' : '?';
+ const selected = r.runId === currentRunId ? ' selected' : '';
+ return '<option value="' + r.runId + '"' + selected + '>' + statusEmoji + ' Run ' + (i+1) + ' - ' + date + '</option>';
+ }).join('') +
+ '</select></div>' +
+ '<div class="run-nav">' +
+ '<button id="prevRun"' + (runIdx <= 0 ? ' disabled' : '') + '>← Prev</button>' +
+ '<button id="nextRun"' + (runIdx >= currentRuns.length - 1 ? ' disabled' : '') + '>Next →</button>' +
+ '</div>';
}
+
+ document.getElementById('commitInfo').innerHTML =
+ '<div class="status-line">' +
+ '<span class="status-icon ' + statusClass + '">' + statusIcon + '</span>' +
+ '<span class="hash">' + hash.slice(0,7) + '</span></div>' +
+ '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>' +
+ runSelectorHtml;
+
+ // Re-attach event listeners
+ const runSelect = document.getElementById('runSelect');
+ if (runSelect) {
+ runSelect.onchange = (e) => selectRun(hash, e.target.value);
+ }
+ const prevBtn = document.getElementById('prevRun');
+ const nextBtn = document.getElementById('nextRun');
+ if (prevBtn) {
+ prevBtn.onclick = () => {
+ const idx = currentRuns.findIndex(r => r.runId === currentRunId);
+ if (idx > 0) selectRun(hash, currentRuns[idx-1].runId);
+ };
+ }
+ if (nextBtn) {
+ nextBtn.onclick = () => {
+ const idx = currentRuns.findIndex(r => r.runId === currentRunId);
+ if (idx < currentRuns.length - 1) selectRun(hash, currentRuns[idx+1].runId);
+ };
+ }
+
+ // Update files
+ currentFiles = info.files || [];
+ const fileList = document.getElementById('fileList');
+ fileList.innerHTML = currentFiles.map(f =>
+ '<li class="file-item" data-file="' + f + '">' + f + '</li>'
+ ).join('');
+ fileList.querySelectorAll('.file-item').forEach(el => {
+ el.onclick = () => loadFile(el.dataset.file);
+ });
+
+ // Update URL
+ history.pushState(null, '', '/jci/' + hash + '/' + runId);
+
+ // Load default file
+ const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0];
+ if (defaultFile) loadFile(defaultFile, true);
}
function escapeHtml(t) {
@@ -571,8 +885,25 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
window.onpopstate = () => {
const m = location.pathname.match(/^\/jci\/([a-f0-9]+)(?:\/(.+))?/);
if (m) {
- if (m[1] !== currentCommit) selectCommitByHash(m[1]);
- else if (m[2] && m[2] !== currentFile) loadFile(m[2]);
+ const commit = m[1];
+ const file = m[2] || null;
+ if (commit !== currentCommit) {
+ selectCommitByHash(commit);
+ } else if (file && file !== currentFile) {
+ loadFile(file, true);
+ } else if (!file && currentFile) {
+ // Went back to commit-only URL
+ currentFile = null;
+ const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0];
+ if (defaultFile) loadFile(defaultFile, true);
+ }
+ } else if (location.pathname === '/') {
+ currentCommit = null;
+ currentFile = null;
+ document.querySelectorAll('.commit-item').forEach(el => el.classList.remove('selected'));
+ document.getElementById('filesPanel').classList.add('hidden');
+ document.getElementById('contentHeader').innerHTML = '';
+ document.getElementById('contentBody').innerHTML = '<div class="empty-state">Select a commit</div>';
}
};
@@ -584,8 +915,25 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
`)
}
-func serveFromRef(w http.ResponseWriter, commit string, filePath string) {
- ref := "refs/jci/" + commit
+func serveFromRef(w http.ResponseWriter, commit string, runID string, filePath string) {
+ var ref string
+
+ // Build ref based on whether we have a runID
+ if runID != "" {
+ ref = "refs/jci-runs/" + commit + "/" + runID
+ } else {
+ ref = "refs/jci/" + commit
+ // If single-run ref doesn't exist, try to find latest run
+ if !RefExists(ref) {
+ runRefs, _ := ListJCIRunRefs()
+ for _, r := range runRefs {
+ if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") {
+ ref = r // Use last matching (sorted by timestamp)
+ }
+ }
+ }
+ }
+
if !RefExists(ref) {
http.Error(w, "CI results not found for commit: "+commit, 404)
return