From 02e7d3fcbac32fbfa8857e4d4f7adfb4e3d0087a Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 20 Aug 2026 12:39:28 -0400 Subject: [PATCH] feat(system): report repository status for git-clone installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/system/update/check` answers "is there a newer published release tag?", which is the right question for an npm install but not for a git clone that tracks a branch. Such an install can be many commits behind its own remote while the latest tag says it is current, and nothing surfaces that. Adds `GET /api/system/repo-status`: an informational companion that reports what this CHECKOUT looks like against its own remotes — current branch and commit, ahead/behind counts per remote, the remote's role (tracking / upstream / other), and a bounded list of incoming commits. Read-only and defensive: every git invocation is `execFileSync` with an argv array and a timeout, a non-git or remote-less install reports a structured `error` rather than throwing, and nothing here mutates the working tree or touches the updater's own state. Tests: 24 cases in test/repo-status.test.ts. --- src/types/update.ts | 49 ++++++ src/web/repo-status.ts | 297 ++++++++++++++++++++++++++++++++ src/web/routes/system-routes.ts | 6 + test/repo-status.test.ts | 159 +++++++++++++++++ 4 files changed, 511 insertions(+) create mode 100644 src/web/repo-status.ts create mode 100644 test/repo-status.test.ts diff --git a/src/types/update.ts b/src/types/update.ts index 289b3e4a..b8751257 100644 --- a/src/types/update.ts +++ b/src/types/update.ts @@ -98,3 +98,52 @@ export interface UpdateCheckResult { source: 'github-api' | 'git-ls-remote' | 'none'; error?: string; } + +/** + * Role of a remote in the repository-status view. + * - `tracking`: the current branch's `@{upstream}` remote (where `git pull` goes). + * - `upstream`: the canonical project (a remote named `origin`/`upstream` that is + * not the tracking remote). + * - `other`: anything else explicitly requested via `CODEMAN_UPDATE_REMOTES`. + */ +export type RepoRemoteRole = 'tracking' | 'upstream' | 'other'; + +/** A single incoming commit — present on the remote ref but not in local HEAD. */ +export interface RepoIncomingCommit { + /** Abbreviated SHA. */ + sha: string; + /** Commit subject (first line). */ + subject: string; +} + +/** Ahead/behind + incoming summary for local HEAD vs one remote's compare ref. */ +export interface RepoRemoteStatus { + /** Remote name, e.g. `origin`, `bitbucket`. */ + name: string; + /** Remote URL (best-effort; empty if unresolved). */ + url: string; + role: RepoRemoteRole; + /** Ref HEAD is compared against, e.g. `origin/master`, `bitbucket/local`. */ + compareRef: string; + /** Commits in local HEAD not on the remote ref (local-only / unpushed). */ + ahead: number; + /** Commits on the remote ref not in local HEAD (incoming). */ + behind: number; + /** Up to N most recent incoming commits (newest first). */ + incoming: RepoIncomingCommit[]; + /** Set when this remote could not be fetched/compared. */ + error?: string; +} + +/** Result of the repository-status check across the configured remotes. */ +export interface RepositoryStatusResult { + /** epoch ms of the check. */ + checkedAt: number; + /** False when this is not a git install (then `remotes` is empty + `error` set). */ + isGit: boolean; + /** Current running version, for display. */ + currentVersion: string; + remotes: RepoRemoteStatus[]; + /** Top-level error (e.g. not a git install, or no remotes resolved). */ + error?: string; +} diff --git a/src/web/repo-status.ts b/src/web/repo-status.ts new file mode 100644 index 00000000..c4858675 --- /dev/null +++ b/src/web/repo-status.ts @@ -0,0 +1,297 @@ +/** + * @fileoverview Repository-status check (App Settings → Updates → "Repository + * status"). + * + * INFORMATIONAL companion to the release-tag self-updater (`self-update.ts`). + * Where the updater answers "is there a newer published release tag, and do you + * want to `git checkout` it", this module answers "where does my local checkout + * sit relative to the upstream project AND my own fork" — by commit ahead/behind + * count plus a short list of the incoming commits. + * + * This needs the actual commits locally, so it does a READ-ONLY `git fetch` of + * the compared ref per remote (updates only remote-tracking refs under `.git`, + * never the working tree), then `git rev-list`/`git log`. Works uniformly for + * GitHub and non-GitHub remotes (e.g. Bitbucket) — no release tags required. + * + * Remote selection (generalizable): by default the union of `origin` and the + * current branch's `@{upstream}` tracking remote, deduped. Override with the + * `CODEMAN_UPDATE_REMOTES` env var (comma-separated remote names) for any other + * layout (e.g. the common `origin`=fork / `upstream`=canonical convention). + * + * Split PURE helpers (parsing + remote-set/role/compare-ref decisions, unit + * tested) from the IO wrapper `getRepositoryStatus()` (touches git). + * + * @module web/repo-status + */ + +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { getInstallInfo } from './self-update.js'; +import type { RepoIncomingCommit, RepoRemoteRole, RepoRemoteStatus, RepositoryStatusResult } from '../types/update.js'; + +const require = createRequire(import.meta.url); +const { version: APP_VERSION } = require('../../package.json') as { version: string }; + +/** Network/git timeout for the fetch path (longer than EXEC_TIMEOUT_MS — hits network). */ +const FETCH_TIMEOUT_MS = 15_000; +/** Max incoming commit subjects to list per remote. */ +const MAX_INCOMING = 10; + +// ───────────────────────────────────────────────────────────────────────────── +// PURE helpers (unit tested, no IO) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Parse `git rev-list --left-right --count HEAD...` output. + * Git prints two tab-separated counts: LEFT (commits in HEAD not in ref → ahead) + * and RIGHT (commits in ref not in HEAD → behind). Returns null on malformed input. + */ +export function parseAheadBehind(out: string): { ahead: number; behind: number } | null { + const m = out.trim().match(/^(\d+)\s+(\d+)$/); + if (!m) return null; + return { ahead: parseInt(m[1], 10), behind: parseInt(m[2], 10) }; +} + +/** + * Parse `git log --oneline` output into commits. Each non-empty line is + * ` `; the first whitespace-delimited token is the SHA. + */ +export function parseLogLines(out: string): RepoIncomingCommit[] { + const commits: RepoIncomingCommit[] = []; + for (const line of out.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const idx = trimmed.indexOf(' '); + if (idx === -1) { + commits.push({ sha: trimmed, subject: '' }); + } else { + commits.push({ sha: trimmed.slice(0, idx), subject: trimmed.slice(idx + 1).trim() }); + } + } + return commits; +} + +/** + * Extract the default branch name from `git ls-remote --symref HEAD` + * output (a line like `ref: refs/heads/master\tHEAD`). Returns null if absent. + * + * SECURITY: this parses UNTRUSTED remote output, and the result flows into a + * later `git fetch ` positional. The capture is constrained to + * start with an alphanumeric (no leading `-`) so a hostile remote can't return + * `ref: refs/heads/--upload-pack=\tHEAD` and smuggle an argv flag (RCE) — + * see `isSafeGitPositional` for the defense-in-depth re-check at the call site. + */ +export function parseSymrefDefaultBranch(out: string): string | null { + const m = out.match(/^ref:\s+refs\/heads\/([A-Za-z0-9_./][A-Za-z0-9_./+-]*)\s+HEAD$/m); + return m ? m[1] : null; +} + +/** + * Reject a value that would be unsafe as a git positional argument (remote name + * or branch). A leading `-` lets untrusted ls-remote/symref output or a stray + * `CODEMAN_UPDATE_REMOTES` entry inject an option (e.g. `--upload-pack=`) + * into a subsequent `git fetch`. Empty values are rejected too. + */ +export function isSafeGitPositional(value: string | null | undefined): value is string { + return typeof value === 'string' && value.length > 0 && !value.startsWith('-'); +} + +/** Split a comma-separated env value into trimmed, non-empty names. */ +export function parseRemotesEnv(value: string | null | undefined): string[] { + if (!value) return []; + return value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Decide which remote NAMES the status view covers, in display order. + * + * - If `envRemotes` is non-empty, use exactly those that actually exist (order + * preserved). This is the full-control escape hatch. + * - Otherwise the union of `origin` (if it exists) and the tracking remote (if + * any), deduped. The tracking remote is listed first when it isn't `origin`, + * so "your fork" leads and "upstream" follows. + */ +export function resolveRemoteSet(opts: { + existingRemotes: string[]; + trackingRemote: string | null; + envRemotes: string[]; +}): string[] { + const exists = new Set(opts.existingRemotes); + if (opts.envRemotes.length > 0) { + return dedupe(opts.envRemotes.filter((n) => exists.has(n))); + } + const out: string[] = []; + if (opts.trackingRemote && exists.has(opts.trackingRemote) && opts.trackingRemote !== 'origin') { + out.push(opts.trackingRemote); + } + if (exists.has('origin')) out.push('origin'); + if (opts.trackingRemote && exists.has(opts.trackingRemote)) out.push(opts.trackingRemote); + return dedupe(out); +} + +/** Classify a remote's role relative to the tracking remote. */ +export function roleForRemote(name: string, trackingRemote: string | null): RepoRemoteRole { + if (trackingRemote && name === trackingRemote) return 'tracking'; + if (name === 'origin' || name === 'upstream') return 'upstream'; + return 'other'; +} + +function dedupe(names: string[]): string[] { + return [...new Set(names)]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// IO wrapper +// ───────────────────────────────────────────────────────────────────────────── + +interface GitResult { + ok: boolean; + stdout: string; + stderr: string; +} + +/** + * Run git non-interactively (no credential or SSH prompts — a missing key/cred + * fails fast instead of hanging). Returns captured stdout/stderr and ok flag. + */ +function runGit(args: string[], cwd: string, timeout = EXEC_TIMEOUT_MS): GitResult { + try { + const stdout = execFileSync('git', args, { + cwd, + encoding: 'utf-8', + timeout, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? 'ssh -oBatchMode=yes', + }, + }); + return { ok: true, stdout: stdout.trim(), stderr: '' }; + } catch (err: unknown) { + const e = err as { stdout?: Buffer | string; stderr?: Buffer | string }; + return { + ok: false, + stdout: e.stdout ? String(e.stdout).trim() : '', + stderr: e.stderr ? String(e.stderr).trim() : '', + }; + } +} + +/** First line of stderr, trimmed — a compact human-readable failure reason. */ +function firstLine(s: string): string { + return ( + s + .split('\n') + .find((l) => l.trim()) + ?.trim() ?? 'git command failed' + ); +} + +/** Compute ahead/behind + incoming commits for one already-selected remote. */ +function statusForRemote( + dir: string, + name: string, + trackingRemote: string, + trackingRef: string | null +): RepoRemoteStatus { + const role = roleForRemote(name, trackingRemote); + const url = runGit(['remote', 'get-url', name], dir).stdout || ''; + const base: RepoRemoteStatus = { name, url, role, compareRef: '', ahead: 0, behind: 0, incoming: [] }; + + // SECURITY: the remote name reaches git as a positional; reject a `-` prefix + // (e.g. a stray CODEMAN_UPDATE_REMOTES entry) before it can act as a flag. + if (!isSafeGitPositional(name)) { + return { ...base, error: `Refusing unsafe remote name "${name}".` }; + } + + // Resolve the compare ref + the remote branch to fetch. + let branch: string | null; + if (role === 'tracking' && trackingRef) { + // e.g. trackingRef = "bitbucket/local" → branch = "local" + branch = trackingRef.slice(name.length + 1) || null; + } else { + const symref = runGit(['ls-remote', '--symref', name, 'HEAD'], dir, FETCH_TIMEOUT_MS); + branch = symref.ok ? parseSymrefDefaultBranch(symref.stdout) : null; + if (!branch && !symref.ok) { + return { ...base, error: `Could not reach ${name}: ${firstLine(symref.stderr)}` }; + } + branch = branch ?? 'master'; + } + if (!branch) return { ...base, error: `Could not resolve a branch on ${name}.` }; + // SECURITY: defense-in-depth — `branch` may come from untrusted symref output + // or a tracking-ref slice; never let a `-`-prefixed value reach `git fetch`. + if (!isSafeGitPositional(branch)) { + return { ...base, error: `Refusing unsafe branch name "${branch}" from ${name}.` }; + } + const compareRef = `${name}/${branch}`; + + // Read-only fetch of just that ref so the local rev-list/log can see it. + // `--` ends option parsing so neither `name` nor `branch` can be read as a flag. + const fetched = runGit(['fetch', '--no-tags', name, '--', branch], dir, FETCH_TIMEOUT_MS); + if (!fetched.ok) { + return { ...base, compareRef, error: `Could not fetch ${compareRef}: ${firstLine(fetched.stderr)}` }; + } + + const counts = runGit(['rev-list', '--left-right', '--count', `HEAD...${compareRef}`], dir); + if (!counts.ok) { + return { ...base, compareRef, error: `Could not compare against ${compareRef}: ${firstLine(counts.stderr)}` }; + } + const ab = parseAheadBehind(counts.stdout); + if (!ab) return { ...base, compareRef, error: `Unexpected git output comparing ${compareRef}.` }; + + const log = runGit(['log', '--oneline', '-n', String(MAX_INCOMING), `HEAD..${compareRef}`], dir); + const incoming = log.ok ? parseLogLines(log.stdout) : []; + + return { ...base, compareRef, ahead: ab.ahead, behind: ab.behind, incoming }; +} + +/** + * Inspect how the local checkout sits relative to the configured remotes. + * Each remote is fetched + compared independently; a single unreachable remote + * surfaces as that card's `error` and never fails the whole call. + */ +export function getRepositoryStatus(): RepositoryStatusResult { + const checkedAt = Date.now(); + const info = getInstallInfo(); + const base: RepositoryStatusResult = { + checkedAt, + isGit: info.installKind === 'git', + currentVersion: info.currentVersion || APP_VERSION, + remotes: [], + }; + if (info.installKind !== 'git') { + return { ...base, error: 'Not a git install — repository status is unavailable.' }; + } + + const dir = info.installDir; + + // Tracking ref of the current branch, e.g. "bitbucket/local" (empty if none). + const trackingRef = runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], dir).stdout || null; + const trackingRemote = trackingRef ? trackingRef.slice(0, trackingRef.indexOf('/')) || null : null; + + const remotesOut = runGit(['remote'], dir); + const existingRemotes = remotesOut.ok + ? remotesOut.stdout + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) + : []; + + const selected = resolveRemoteSet({ + existingRemotes, + trackingRemote, + envRemotes: parseRemotesEnv(process.env.CODEMAN_UPDATE_REMOTES), + }); + + if (selected.length === 0) { + return { ...base, error: 'No comparable remotes found (set CODEMAN_UPDATE_REMOTES to choose).' }; + } + + const remotes = selected.map((name) => statusForRemote(dir, name, trackingRemote ?? '', trackingRef)); + return { ...base, remotes }; +} diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 87953851..fa074255 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -48,6 +48,7 @@ import { } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js'; +import { getRepositoryStatus } from '../repo-status.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js'; @@ -354,6 +355,11 @@ export function registerSystemRoutes( // Poll target for update progress — survives the restart the update triggers. app.get('/api/system/update/status', async () => getUpdateStatusForApi()); + // Informational companion to the release-tag updater above: what this CHECKOUT + // looks like against its own remotes (ahead/behind, incoming commits), which a + // release tag cannot answer for a git install tracking a branch. + app.get('/api/system/repo-status', async () => getRepositoryStatus()); + // Kick off a detached update to the latest release. Returns immediately; the // browser then polls /api/system/update/status across the service restart. app.post('/api/system/update', async (_req, reply) => { diff --git a/test/repo-status.test.ts b/test/repo-status.test.ts new file mode 100644 index 00000000..3308e254 --- /dev/null +++ b/test/repo-status.test.ts @@ -0,0 +1,159 @@ +/** + * @fileoverview Unit tests for the repository-status pure helpers: ahead/behind + * + log + symref parsing, env parsing, and the remote-set / role decisions. + * No IO, no git, no port — safe to run individually. + * + * npm test -- test/repo-status.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { + parseAheadBehind, + parseLogLines, + parseSymrefDefaultBranch, + parseRemotesEnv, + resolveRemoteSet, + roleForRemote, + isSafeGitPositional, +} from '../src/web/repo-status.js'; + +describe('parseAheadBehind', () => { + it('parses tab-separated left/right counts as ahead/behind', () => { + expect(parseAheadBehind('3\t14')).toEqual({ ahead: 3, behind: 14 }); + }); + it('parses space-separated counts', () => { + expect(parseAheadBehind('0 0')).toEqual({ ahead: 0, behind: 0 }); + }); + it('tolerates surrounding whitespace/newline', () => { + expect(parseAheadBehind(' 5 2 \n')).toEqual({ ahead: 5, behind: 2 }); + }); + it('returns null on malformed output', () => { + expect(parseAheadBehind('')).toBeNull(); + expect(parseAheadBehind('abc')).toBeNull(); + expect(parseAheadBehind('1')).toBeNull(); + }); +}); + +describe('parseLogLines', () => { + it('splits each line into sha + subject', () => { + const out = 'a1b2c3 fix: thing\nd4e5f6 COD-9 add digest'; + expect(parseLogLines(out)).toEqual([ + { sha: 'a1b2c3', subject: 'fix: thing' }, + { sha: 'd4e5f6', subject: 'COD-9 add digest' }, + ]); + }); + it('handles a subject with no space (sha only)', () => { + expect(parseLogLines('deadbee')).toEqual([{ sha: 'deadbee', subject: '' }]); + }); + it('ignores blank lines', () => { + expect(parseLogLines('\n \nabc123 hi\n')).toEqual([{ sha: 'abc123', subject: 'hi' }]); + }); + it('returns [] for empty input', () => { + expect(parseLogLines('')).toEqual([]); + }); +}); + +describe('parseSymrefDefaultBranch', () => { + it('extracts the default branch from ls-remote --symref output', () => { + const out = 'ref: refs/heads/master\tHEAD\n0123abc\tHEAD'; + expect(parseSymrefDefaultBranch(out)).toBe('master'); + }); + it('handles main', () => { + expect(parseSymrefDefaultBranch('ref: refs/heads/main\tHEAD')).toBe('main'); + }); + it('returns null when no symref line present', () => { + expect(parseSymrefDefaultBranch('0123abc\tHEAD')).toBeNull(); + }); + it('rejects a hostile branch that would smuggle a git flag (argv injection)', () => { + // A malicious remote sets HEAD to a `-`-leading "branch"; the capture must + // not start with `-`, so this yields null rather than `--upload-pack=...`. + expect(parseSymrefDefaultBranch('ref: refs/heads/--upload-pack=touch\tHEAD')).toBeNull(); + }); +}); + +describe('isSafeGitPositional', () => { + it('accepts normal remote/branch names', () => { + expect(isSafeGitPositional('origin')).toBe(true); + expect(isSafeGitPositional('feature/foo')).toBe(true); + }); + it('rejects flag-injecting and empty values', () => { + expect(isSafeGitPositional('--upload-pack=touch /tmp/x')).toBe(false); + expect(isSafeGitPositional('-x')).toBe(false); + expect(isSafeGitPositional('')).toBe(false); + expect(isSafeGitPositional(null)).toBe(false); + expect(isSafeGitPositional(undefined)).toBe(false); + }); +}); + +describe('parseRemotesEnv', () => { + it('splits comma list, trims, drops empties', () => { + expect(parseRemotesEnv('origin, bitbucket ,, fork')).toEqual(['origin', 'bitbucket', 'fork']); + }); + it('returns [] for null/undefined/empty', () => { + expect(parseRemotesEnv(null)).toEqual([]); + expect(parseRemotesEnv(undefined)).toEqual([]); + expect(parseRemotesEnv('')).toEqual([]); + }); +}); + +describe('resolveRemoteSet', () => { + it('defaults to tracking-remote first, then origin (the maintainer-fork layout)', () => { + // local branch tracks bitbucket; origin is the canonical upstream. + const set = resolveRemoteSet({ + existingRemotes: ['bitbucket', 'fork', 'origin'], + trackingRemote: 'bitbucket', + envRemotes: [], + }); + expect(set).toEqual(['bitbucket', 'origin']); + }); + + it('collapses to a single entry when origin IS the tracking remote', () => { + const set = resolveRemoteSet({ + existingRemotes: ['origin'], + trackingRemote: 'origin', + envRemotes: [], + }); + expect(set).toEqual(['origin']); + }); + + it('falls back to just origin when there is no tracking remote', () => { + const set = resolveRemoteSet({ + existingRemotes: ['origin', 'fork'], + trackingRemote: null, + envRemotes: [], + }); + expect(set).toEqual(['origin']); + }); + + it('honors CODEMAN_UPDATE_REMOTES order and filters to existing remotes', () => { + const set = resolveRemoteSet({ + existingRemotes: ['origin', 'bitbucket', 'fork'], + trackingRemote: 'bitbucket', + envRemotes: ['fork', 'origin', 'ghost'], + }); + expect(set).toEqual(['fork', 'origin']); // 'ghost' dropped (does not exist) + }); + + it('returns [] when env names none of the existing remotes', () => { + const set = resolveRemoteSet({ + existingRemotes: ['origin'], + trackingRemote: null, + envRemotes: ['nope'], + }); + expect(set).toEqual([]); + }); +}); + +describe('roleForRemote', () => { + it('labels the tracking remote as tracking even if named origin', () => { + expect(roleForRemote('origin', 'origin')).toBe('tracking'); + expect(roleForRemote('bitbucket', 'bitbucket')).toBe('tracking'); + }); + it('labels origin/upstream (when not tracking) as upstream', () => { + expect(roleForRemote('origin', 'bitbucket')).toBe('upstream'); + expect(roleForRemote('upstream', 'origin')).toBe('upstream'); + }); + it('labels anything else as other', () => { + expect(roleForRemote('fork', 'bitbucket')).toBe('other'); + }); +});