mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
feat(system): report repository status for git-clone installs
`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.
This commit is contained in:
@@ -98,3 +98,52 @@ export interface UpdateCheckResult {
|
|||||||
source: 'github-api' | 'git-ls-remote' | 'none';
|
source: 'github-api' | 'git-ls-remote' | 'none';
|
||||||
error?: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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...<ref>` 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
|
||||||
|
* `<sha> <subject>`; 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 <remote> 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 <remote> <branch>` positional. The capture is constrained to
|
||||||
|
* start with an alphanumeric (no leading `-`) so a hostile remote can't return
|
||||||
|
* `ref: refs/heads/--upload-pack=<cmd>\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=<cmd>`)
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
} from '../route-helpers.js';
|
} from '../route-helpers.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.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 type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.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.
|
// Poll target for update progress — survives the restart the update triggers.
|
||||||
app.get('/api/system/update/status', async () => getUpdateStatusForApi());
|
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
|
// Kick off a detached update to the latest release. Returns immediately; the
|
||||||
// browser then polls /api/system/update/status across the service restart.
|
// browser then polls /api/system/update/status across the service restart.
|
||||||
app.post('/api/system/update', async (_req, reply) => {
|
app.post('/api/system/update', async (_req, reply) => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user