feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
This commit is contained in:
@@ -402,6 +402,84 @@ describe('buildProjects — dirty check', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── W3(a) sync chip — ahead/behind vs upstream + last-commit time ──────────────
|
||||
|
||||
describe('buildProjects — sync fields (W3 a)', () => {
|
||||
async function commit(cwd: string, file: string, msg: string): Promise<void> {
|
||||
await fs.writeFile(path.join(cwd, file), `${file}\n`)
|
||||
await execFileP('git', ['add', '.'], { cwd })
|
||||
await execFileP('git', ['commit', '-q', '-m', msg], { cwd })
|
||||
}
|
||||
|
||||
async function initRepo(cwd: string): Promise<void> {
|
||||
await fs.mkdir(cwd, { recursive: true })
|
||||
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd })
|
||||
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd })
|
||||
await execFileP('git', ['config', 'user.name', 'tester'], { cwd })
|
||||
await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd })
|
||||
}
|
||||
|
||||
it('reports ahead/behind vs upstream and lastCommitMs when dirtyCheck is on', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
|
||||
// Upstream repo with one commit; clone it so the clone's main tracks origin/main.
|
||||
const upstream = path.join(tmp, 'upstream')
|
||||
await initRepo(upstream)
|
||||
await commit(upstream, 'a.txt', 'first')
|
||||
|
||||
const cloneRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-clone-'))
|
||||
const clone = path.join(cloneRoot, 'clone')
|
||||
await execFileP('git', ['clone', '-q', upstream, clone])
|
||||
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone })
|
||||
await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone })
|
||||
await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone })
|
||||
|
||||
// Diverge: 1 local commit ahead, then 1 upstream commit fetched → 1 behind.
|
||||
await commit(clone, 'local.txt', 'local ahead')
|
||||
await commit(upstream, 'b.txt', 'second upstream')
|
||||
await execFileP('git', ['fetch', '-q'], { cwd: clone })
|
||||
|
||||
const cfg = makeCfg({ projectRoots: [cloneRoot], projectScanDepth: 2, projectDirtyCheck: true })
|
||||
const out = await buildProjects(cfg, [])
|
||||
const proj = out.find((p) => p.name === 'clone')!
|
||||
expect(proj.ahead).toBe(1)
|
||||
expect(proj.behind).toBe(1)
|
||||
expect(typeof proj.lastCommitMs).toBe('number')
|
||||
expect(proj.lastCommitMs!).toBeGreaterThan(0)
|
||||
|
||||
await fs.rm(cloneRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('leaves ahead/behind undefined for a repo with no upstream (no throw)', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
const repo = path.join(tmp, 'noupstream')
|
||||
await initRepo(repo)
|
||||
await commit(repo, 'a.txt', 'only')
|
||||
|
||||
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: true })
|
||||
const out = await buildProjects(cfg, [])
|
||||
const proj = out.find((p) => p.name === 'noupstream')!
|
||||
expect(proj.ahead).toBeUndefined()
|
||||
expect(proj.behind).toBeUndefined()
|
||||
// lastCommitMs still resolves — HEAD has a commit even without an upstream.
|
||||
expect(typeof proj.lastCommitMs).toBe('number')
|
||||
})
|
||||
|
||||
it('skips sync entirely (all undefined) when projectDirtyCheck is false', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
const repo = path.join(tmp, 'skipsync')
|
||||
await initRepo(repo)
|
||||
await commit(repo, 'a.txt', 'only')
|
||||
|
||||
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
||||
const out = await buildProjects(cfg, [])
|
||||
const proj = out.find((p) => p.name === 'skipsync')!
|
||||
expect(proj.ahead).toBeUndefined()
|
||||
expect(proj.behind).toBeUndefined()
|
||||
expect(proj.lastCommitMs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ── buildProjectDetail ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildProjectDetail', () => {
|
||||
|
||||
Reference in New Issue
Block a user