Jaypore CI

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

commit 0625cbea069d463fe8fe9eb30b5abee71d5437f4
parent 76e0c02842400dabc88448d160e46940732e65a2
Author: Arjoonn@exe.dev <arjoonn+exe.dev@midpathsoftware.com>
Date:   Wed, 25 Feb 2026 05:40:42 +0000

Compact UI with CI push status

- Show CI push status (↑ pushed, ● local) instead of git commit push status
- Responsive layout: sidebar collapses on narrow screens
- Reduced padding throughout for information density
- Shorter commit hashes (7 chars)
- Removed redundant branch status bar
- Minimal header with just title and branch selector
- Dark theme with better contrast

Co-authored-by: Shelley <shelley@exe.dev>

Diffstat:
A.jci/run.sh | 35+++++++++++++++++++++++++++++++++++
Minternal/jci/web.go | 362++++++++++++++++++++++++++++---------------------------------------------------
2 files changed, 163 insertions(+), 234 deletions(-)

diff --git a/.jci/run.sh b/.jci/run.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# CI script for git-jci +# This runs in .jci/<commit>/ directory +# Environment variables available: +# JCI_COMMIT - Full commit hash +# JCI_REPO_ROOT - Repository root path +# JCI_OUTPUT_DIR - Output directory (where artifacts should go) + +set -e + +echo "=== JCI CI Pipeline ===" +echo "Commit: ${JCI_COMMIT:0:12}" +echo "" + +cd "$JCI_REPO_ROOT" + +echo "Running tests..." +go test ./... +echo "" + +echo "Building static binary (CGO_ENABLED=0)..." +CGO_ENABLED=0 go build -ldflags='-s -w -extldflags "-static"' -o "$JCI_OUTPUT_DIR/git-jci" ./cmd/git-jci + +# Verify it's static +echo "" +echo "Binary info:" +file "$JCI_OUTPUT_DIR/git-jci" +ls -lh "$JCI_OUTPUT_DIR/git-jci" + +echo "" +echo "All steps completed successfully!" +echo "" +echo "=== Installation ===" +echo "Download and install with:" +echo " curl -fsSL \$(git jci web --url)/git-jci -o /tmp/git-jci && sudo install /tmp/git-jci /usr/local/bin/" diff --git a/internal/jci/web.go b/internal/jci/web.go @@ -19,12 +19,12 @@ 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"` - IsPushed bool `json:"isPushed"` - HasCI bool `json:"hasCI"` - CIStatus string `json:"ciStatus"` // "success", "failed", or "" + 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 } // Web starts a web server to view CI results @@ -91,25 +91,29 @@ func getLocalBranches() ([]string, error) { return strings.Split(out, "\n"), nil } -// getRemoteCommits returns a set of commits that exist on remote -func getRemoteCommits(remote string) (map[string]bool, error) { - remoteCommits := make(map[string]bool) +// 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 all remote branch tips - out, err := git("branch", "-r", "--format=%(objectname)") + // Get remote JCI refs + out, err := git("ls-remote", "--refs", remote, "refs/jci/*") if err != nil { - return remoteCommits, nil // Not an error if no remotes + return remoteCI } if out == "" { - return remoteCommits, nil + return remoteCI } - for _, hash := range strings.Split(out, "\n") { - if hash != "" { - remoteCommits[hash] = true + 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 remoteCommits, nil + return remoteCI } // getBranchCommits returns recent commits for a branch @@ -123,10 +127,7 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) { return nil, nil } - // Get remote commits for push status - remoteCommits, _ := getRemoteCommits("origin") - - // Get JCI refs for CI status + // Get local JCI refs jciRefs, _ := ListJCIRefs() jciSet := make(map[string]bool) for _, ref := range jciRefs { @@ -134,6 +135,9 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) { jciSet[commit] = true } + // Get remote JCI refs for CI push status + remoteCI := getRemoteJCIRefs("origin") + var commits []CommitInfo for _, line := range strings.Split(out, "\n") { parts := strings.SplitN(line, "|", 2) @@ -145,10 +149,10 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) { commit := CommitInfo{ Hash: hash, - ShortHash: hash[:12], + ShortHash: hash[:7], Message: msg, - IsPushed: isCommitPushed(hash, remoteCommits), HasCI: jciSet[hash], + CIPushed: remoteCI[hash], } if commit.HasCI { @@ -161,22 +165,6 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) { return commits, nil } -// isCommitPushed checks if a commit is reachable from any remote branch -func isCommitPushed(hash string, remoteCommits map[string]bool) bool { - // Quick check: is this exact commit a remote branch tip? - if remoteCommits[hash] { - return true - } - - // Check if commit is ancestor of any remote branch - // Use merge-base to check reachability - out, err := git("branch", "-r", "--contains", hash) - if err != nil { - return false - } - return strings.TrimSpace(out) != "" -} - // getCIStatus returns "success" or "failed" based on CI results func getCIStatus(commit string) string { // Try to read the index.html and look for status @@ -228,254 +216,168 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { <html> <head> <meta charset="utf-8"> - <title>JCI - CI Dashboard</title> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>JCI</title> <style> - * { box-sizing: border-box; } + * { box-sizing: border-box; margin: 0; padding: 0; } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - margin: 0; - padding: 0; - background: #f5f5f5; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; + font-size: 12px; + background: #1a1a1a; + color: #e0e0e0; display: flex; - min-height: 100vh; - } - .sidebar { - width: 300px; - background: #24292e; - color: #fff; - padding: 20px; - overflow-y: auto; + flex-direction: column; + height: 100vh; } - .sidebar h1 { - font-size: 20px; - margin: 0 0 20px 0; - padding-bottom: 10px; - border-bottom: 1px solid #444; + .header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + background: #252525; + border-bottom: 1px solid #333; } + .header h1 { font-size: 12px; font-weight: 600; color: #888; } .branch-selector { - width: 100%; - padding: 8px 12px; - font-size: 14px; + padding: 2px 6px; + font-size: 12px; border: 1px solid #444; - border-radius: 6px; - background: #2d333b; + border-radius: 3px; + background: #2a2a2a; color: #fff; - margin-bottom: 16px; cursor: pointer; } - .branch-selector:focus { - outline: none; - border-color: #58a6ff; + .branch-selector:focus { outline: none; border-color: #58a6ff; } + .container { + display: flex; + flex: 1; + overflow: hidden; } - .commit-list { - list-style: none; - padding: 0; - margin: 0; + .sidebar { + width: 280px; + background: #1e1e1e; + border-right: 1px solid #333; + overflow-y: auto; + flex-shrink: 0; } + .commit-list { list-style: none; } .commit-item { - padding: 10px; - border-radius: 6px; - margin-bottom: 4px; + padding: 4px 6px; cursor: pointer; - transition: background 0.2s; display: flex; - align-items: flex-start; - gap: 10px; - } - .commit-item:hover { - background: #2d333b; + align-items: center; + gap: 6px; + border-bottom: 1px solid #2a2a2a; } - .commit-status { - width: 12px; - height: 12px; + .commit-item:hover { background: #2a2a2a; } + .commit-item.selected { background: #2d4a3e; } + .ci-dot { + width: 8px; + height: 8px; border-radius: 50%; flex-shrink: 0; - margin-top: 4px; - } - .commit-status.success { background: #3fb950; } - .commit-status.failed { background: #f85149; } - .commit-status.none { background: #484f58; } - .commit-info { - flex: 1; - min-width: 0; } + .ci-dot.success { background: #3fb950; } + .ci-dot.failed { background: #f85149; } + .ci-dot.none { background: #484f58; } .commit-hash { font-family: monospace; - font-size: 12px; + font-size: 11px; color: #58a6ff; + width: 56px; + flex-shrink: 0; } .commit-msg { - font-size: 13px; - color: #c9d1d9; + flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + color: #aaa; } - .commit-badges { - display: flex; - gap: 6px; - margin-top: 4px; - } - .badge { - font-size: 10px; - padding: 2px 6px; - border-radius: 10px; + .ci-badge { + font-size: 9px; + padding: 1px 4px; + border-radius: 3px; font-weight: 500; + flex-shrink: 0; } - .badge.local { - background: #f0883e; - color: #000; - } - .badge.pushed { - background: #238636; - color: #fff; - } - .badge.ci { - background: #388bfd; - color: #fff; - } + .ci-badge.local { background: #6e4a1a; color: #f0a040; } + .ci-badge.pushed { background: #1a4a2a; color: #40c060; } .main-content { flex: 1; - padding: 20px; + display: flex; + flex-direction: column; + min-width: 0; } .main-content iframe { - width: 100%; - height: calc(100vh - 40px); + flex: 1; border: none; - border-radius: 8px; background: #fff; } .no-selection { + flex: 1; display: flex; align-items: center; justify-content: center; - height: calc(100vh - 40px); - background: #fff; - border-radius: 8px; color: #666; - font-size: 16px; - } - .branch-status { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - background: #2d333b; - border-radius: 6px; - margin-bottom: 12px; font-size: 13px; } - .branch-ci-status { - width: 10px; - height: 10px; - border-radius: 50%; - } - .loading { - color: #8b949e; - font-style: italic; - padding: 20px; + @media (max-width: 600px) { + .container { flex-direction: column; } + .sidebar { width: 100%; height: 40%; border-right: none; border-bottom: 1px solid #333; } + .main-content { height: 60%; } } </style> </head> <body> - <div class="sidebar"> - <h1>🔧 JCI Dashboard</h1> - <select class="branch-selector" id="branchSelect"> - <option value="">Loading branches...</option> - </select> - <div class="branch-status" id="branchStatus" style="display: none;"> - <div class="branch-ci-status" id="branchCIStatus"></div> - <span id="branchStatusText">Branch CI Status</span> - </div> - <ul class="commit-list" id="commitList"> - <li class="loading">Loading...</li> - </ul> + <div class="header"> + <h1>JCI</h1> + <select class="branch-selector" id="branchSelect"></select> </div> - <div class="main-content"> - <div class="no-selection" id="noSelection"> - Select a commit to view CI results + <div class="container"> + <div class="sidebar"> + <ul class="commit-list" id="commitList"></ul> + </div> + <div class="main-content"> + <div class="no-selection" id="noSelection">Select commit</div> + <iframe id="ciFrame" style="display: none;"></iframe> </div> - <iframe id="ciFrame" style="display: none;"></iframe> </div> - <script> - let branches = []; - let currentBranch = ''; + let branches = [], selectedHash = null; async function loadBranches() { - try { - const res = await fetch('/api/branches'); - branches = await res.json(); - - const select = document.getElementById('branchSelect'); - select.innerHTML = branches.map(b => - '"<option value="' + b.name + '">' + b.name + '</option>' - ).join(''); - - // Select main or first branch - const defaultBranch = branches.find(b => b.name === 'main') || branches[0]; - if (defaultBranch) { - select.value = defaultBranch.name; - showBranch(defaultBranch.name); - } - } catch (err) { - console.error('Failed to load branches:', err); - } + const res = await fetch('/api/branches'); + branches = await res.json() || []; + const select = document.getElementById('branchSelect'); + select.innerHTML = branches.map(b => '<option value="' + b.name + '">' + b.name + '</option>').join(''); + const def = branches.find(b => b.name === 'main') || branches[0]; + if (def) { select.value = def.name; showBranch(def.name); } } - function showBranch(branchName) { - currentBranch = branchName; - const branch = branches.find(b => b.name === branchName); + function showBranch(name) { + const branch = branches.find(b => b.name === name); if (!branch) return; - const list = document.getElementById('commitList'); - const branchStatus = document.getElementById('branchStatus'); - - // Determine branch CI status from first commit with CI - const firstCICommit = branch.commits.find(c => c.hasCI); - if (firstCICommit) { - branchStatus.style.display = 'flex'; - const statusEl = document.getElementById('branchCIStatus'); - const textEl = document.getElementById('branchStatusText'); - if (firstCICommit.ciStatus === 'success') { - statusEl.style.background = '#3fb950'; - textEl.textContent = 'Latest CI: Passing'; - } else if (firstCICommit.ciStatus === 'failed') { - statusEl.style.background = '#f85149'; - textEl.textContent = 'Latest CI: Failed'; - } else { - statusEl.style.background = '#484f58'; - textEl.textContent = 'Latest CI: Unknown'; - } - } else { - branchStatus.style.display = 'none'; - } - - list.innerHTML = branch.commits.map(c => { - let badges = ''; - if (c.isPushed) { - badges += '<span class="badge pushed">pushed</span>'; - } else { - badges += '<span class="badge local">local</span>'; - } - if (c.hasCI) { - badges += '<span class="badge ci">CI</span>'; - } - const statusClass = c.hasCI ? c.ciStatus : 'none'; - return '<li class="commit-item" onclick="showCommit(\'' + c.hash + '\', ' + c.hasCI + ')">' + - '<div class="commit-status ' + statusClass + '"></div>' + - '<div class="commit-info">' + - '<div class="commit-hash">' + c.shortHash + '</div>' + - '<div class="commit-msg">' + escapeHtml(c.message) + '</div>' + - '<div class="commit-badges">' + badges + '</div>' + - '</div></li>'; + list.innerHTML = (branch.commits || []).map(c => { + const status = c.hasCI ? c.ciStatus : 'none'; + const badge = c.hasCI ? (c.ciPushed ? '<span class="ci-badge pushed">↑</span>' : '<span class="ci-badge local">●</span>') : ''; + return '<li class="commit-item" data-hash="' + c.hash + '" data-hasci="' + c.hasCI + '">' + + '<span class="ci-dot ' + status + '"></span>' + + '<span class="commit-hash">' + c.shortHash + '</span>' + + '<span class="commit-msg">' + escapeHtml(c.message) + '</span>' + + badge + '</li>'; }).join(''); + list.querySelectorAll('.commit-item').forEach(el => { + el.onclick = () => showCommit(el.dataset.hash, el.dataset.hasci === 'true'); + }); } function showCommit(hash, hasCI) { + selectedHash = hash; + document.querySelectorAll('.commit-item').forEach(el => el.classList.toggle('selected', el.dataset.hash === hash)); const frame = document.getElementById('ciFrame'); const noSel = document.getElementById('noSelection'); - if (hasCI) { frame.src = '/jci/' + hash + '/'; frame.style.display = 'block'; @@ -483,20 +385,12 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { } else { frame.style.display = 'none'; noSel.style.display = 'flex'; - noSel.textContent = 'No CI results for this commit. Run: git jci run'; + noSel.textContent = 'No CI - run: git jci run'; } } - function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - document.getElementById('branchSelect').addEventListener('change', (e) => { - showBranch(e.target.value); - }); - + function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } + document.getElementById('branchSelect').onchange = e => showBranch(e.target.value); loadBranches(); </script> </body>