mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(ui): git status indicator in the bottom bar, with a panel of uncommitted and unpushed work
Optional and per-device (showGitStatus, default off). GET /api/sessions/:id/git-status is read-only and offline (no fetch, --no-optional-locks), skips remote and Docker sessions, caps its lists, and single-flights concurrent polls. The toolbar indicator shows uncommitted files, commits not pushed, or a check; clicking opens a draggable panel in the style of the Files window. Which repositories: the enclosing one when there is one; otherwise every repository up to two levels below the working directory (capped, skipping dot-folders and node_modules, never following symlinks), each in a collapsible section, with the indicator summing them. A repository that merely sits above the workspace and is the home folder or higher (a dotfiles repo) is ignored. Git-supplied text is only ever written with textContent. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
ffaa5ee80c
commit
bfc164a262
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"aicodeman": minor
|
||||
---
|
||||
|
||||
Git status in the bottom bar. Agents leave work uncommitted and unpushed; turn on Settings → Header & Panels → Bottom bar → "Git status" (per-device, off by default) and a small indicator at the right of the bottom bar shows the active session's repository at a glance (`● 3` uncommitted files, `↑ 2` commits not pushed, `✓` when everything is committed and pushed). Click it for a draggable window, like the Files window, listing exactly which files are uncommitted (staged, not staged, untracked, merge conflicts; click one to preview it) and which commits are not pushed. When a session's folder holds several projects rather than being a repository itself, every repository found up to two levels down gets its own collapsible section and the indicator adds them up; an unrelated repository above the workspace (such as a dotfiles repo in your home folder) is ignored. Read-only and offline: Codeman never fetches or changes the repository, so "behind" is as of your last fetch. Not shown for Docker or remote sessions. New `GET /api/sessions/:id/git-status`.
|
||||
@@ -35,6 +35,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/shift-enter-keypress.browser.test.ts',
|
||||
'test/key-tester.browser.test.ts',
|
||||
'test/webhook-settings.browser.test.ts',
|
||||
'test/git-status.browser.test.ts',
|
||||
'test/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
'test/mobile-ime-preview.browser.test.ts',
|
||||
|
||||
@@ -700,6 +700,27 @@ normal `caseName`/`mode`/etc. body)
|
||||
jarring than a full relaunch, and folding it into the one-shot path is
|
||||
separate work — see `docs/custom-model-endpoints-plan.md`).
|
||||
|
||||
## Git status
|
||||
|
||||
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`).
|
||||
|
||||
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
|
||||
|
||||
- Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
|
||||
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most 12 (`reposTruncated` says when there were more). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
|
||||
- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored.
|
||||
- A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
|
||||
|
||||
`data` is `{ state, repos, reposTruncated, checkedAt }`:
|
||||
|
||||
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
|
||||
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
|
||||
- `state: 'not-a-repo'`: no repository here, above (that counts) or within two levels below.
|
||||
- `state: 'unsupported'` with `reason: 'remote' | 'docker'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host).
|
||||
- `state: 'error'` with a short `error` (git missing, timed out, or git's first stderr line with any `user:token@` credentials redacted).
|
||||
|
||||
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
|
||||
|
||||
## CLI management
|
||||
|
||||
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* @fileoverview "What has this session's workspace not committed or pushed?": a read-only git
|
||||
* snapshot of a session's working directory, for the bottom-bar Git indicator and its panel
|
||||
* (`GET /api/sessions/:id/git-status`). Agents leave work uncommitted and unpushed; this makes that
|
||||
* visible without leaving Codeman.
|
||||
*
|
||||
* Split so the parts that matter test without a repo:
|
||||
* - pure: `parsePorcelainV2` (status output → branch, upstream, ahead/behind, per-file entries),
|
||||
* `parseCommitLog`
|
||||
* - IO: `getGitWorkspaceStatus` (a handful of async, bounded, read-only `git` calls), with a short
|
||||
* single-flight cache so several tabs polling one repo cost one set of git processes
|
||||
*
|
||||
* WHICH repositories. `getGitWorkspaceOverview` answers for the session's working directory:
|
||||
* - inside a repository (or at its root): that one repository. git finds it by walking UP, so a
|
||||
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
|
||||
* to the outer one, and is not scanned;
|
||||
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
|
||||
* DOWN (`MAX_REPOS` of them, skipping dot-folders, `node_modules` and the like, never following
|
||||
* symlinks), each reported separately;
|
||||
* - a repository that merely sits ABOVE the workspace and is the home folder or higher (a dotfiles
|
||||
* repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work.
|
||||
*
|
||||
* Rules the code keeps and the tests pin:
|
||||
* - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects
|
||||
* the last fetch (the UI says so); "ahead" and the unpushed list are exact against the
|
||||
* remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even
|
||||
* refreshing the index, so polling cannot contend with the agent's own git commands.
|
||||
* - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a
|
||||
* shell: the working directory is the process `cwd`, and the only operand-like input is a fixed
|
||||
* revision range.
|
||||
* - Output is capped: the counts are exact, the lists are not (`filesTruncated`).
|
||||
* - git can run helpers a repository configures (`core.fsmonitor`, clean filters). A LOCAL session
|
||||
* already runs as this same OS user, so polling adds no privilege; `core.fsmonitor` is turned off
|
||||
* anyway. Remote and Docker sessions are never inspected (the route answers `unsupported`):
|
||||
* a Docker workspace is writable from inside a sandbox and git here would run on the host.
|
||||
* - Remote URLs and git's stderr can embed `user:token@host`; anything that reaches a client goes
|
||||
* through `redactGitCredentials`.
|
||||
*
|
||||
* @module git-workspace-status
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, join, relative, sep } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
|
||||
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||
/** Max file rows returned. The counts stay exact. */
|
||||
export const MAX_FILES = 300;
|
||||
/** Max unpushed commits listed. The count stays exact. */
|
||||
export const MAX_COMMITS = 50;
|
||||
/** A fresh-enough result is reused, so N tabs on one repo cost one set of git calls. */
|
||||
const CACHE_TTL_MS = 4000;
|
||||
const CACHE_MAX_ENTRIES = 64;
|
||||
|
||||
export type GitFileKind = 'staged' | 'unstaged' | 'untracked' | 'conflicted';
|
||||
|
||||
export interface GitFileEntry {
|
||||
/** Path relative to the repository root, as git reports it. */
|
||||
path: string;
|
||||
/** Rename/copy source, when the entry is one. */
|
||||
origPath?: string;
|
||||
/** Status letter in the index (`M`, `A`, `D`, `R`, `C`, `T`, `.`). */
|
||||
index: string;
|
||||
/** Status letter in the working tree (`M`, `D`, `T`, `.`, ...). `?` for untracked. */
|
||||
worktree: string;
|
||||
kind: GitFileKind;
|
||||
}
|
||||
|
||||
export interface GitCommitEntry {
|
||||
hash: string;
|
||||
author: string;
|
||||
/** Seconds since the epoch. */
|
||||
time: number;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface GitWorkspaceStatus {
|
||||
/**
|
||||
* `ok`: a repository, the rest of the fields are meaningful. `not-a-repo`: nothing to show.
|
||||
* `unsupported`: a remote or Docker session (never inspected). `error`: git failed; see `error`.
|
||||
*/
|
||||
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||
reason?: 'remote' | 'docker';
|
||||
error?: string;
|
||||
repoRoot?: string;
|
||||
/** Null when HEAD is detached. */
|
||||
branch: string | null;
|
||||
detached: boolean;
|
||||
upstream: string | null;
|
||||
ahead: number;
|
||||
/** Behind the remote-tracking ref as of the LAST FETCH; this module never fetches. */
|
||||
behind: number;
|
||||
/** Whether the repository has any remote at all. */
|
||||
hasRemote: boolean;
|
||||
counts: {
|
||||
staged: number;
|
||||
unstaged: number;
|
||||
untracked: number;
|
||||
conflicted: number;
|
||||
/** Distinct paths that are not committed. */
|
||||
uncommitted: number;
|
||||
stashes: number;
|
||||
};
|
||||
files: GitFileEntry[];
|
||||
filesTruncated: boolean;
|
||||
/** Commits on this branch that no remote has: exact. */
|
||||
unpushedCount: number;
|
||||
unpushed: GitCommitEntry[];
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
const EMPTY: Omit<GitWorkspaceStatus, 'state' | 'checkedAt'> = {
|
||||
branch: null,
|
||||
detached: false,
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: false,
|
||||
counts: { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 },
|
||||
files: [],
|
||||
filesTruncated: false,
|
||||
unpushedCount: 0,
|
||||
unpushed: [],
|
||||
};
|
||||
|
||||
export const emptyStatus = (
|
||||
state: GitWorkspaceStatus['state'],
|
||||
extra: Partial<GitWorkspaceStatus> = {}
|
||||
): GitWorkspaceStatus => ({ ...EMPTY, counts: { ...EMPTY.counts }, state, checkedAt: Date.now(), ...extra });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ParsedStatus {
|
||||
branch: string | null;
|
||||
detached: boolean;
|
||||
upstream: string | null;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
files: GitFileEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git status --porcelain=v2 --branch -z`. Entries are NUL-separated and paths are NOT quoted,
|
||||
* so a name with spaces, quotes or a newline arrives intact. A rename/copy (`2 ...`) is followed by
|
||||
* one more NUL-terminated token holding the original path.
|
||||
*/
|
||||
export function parsePorcelainV2(text: string): ParsedStatus {
|
||||
const out: ParsedStatus = { branch: null, detached: false, upstream: null, ahead: 0, behind: 0, files: [] };
|
||||
const tokens = text.split('\0');
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
if (!t) continue;
|
||||
if (t.startsWith('# ')) {
|
||||
const [key, ...rest] = t.slice(2).split(' ');
|
||||
const value = rest.join(' ');
|
||||
if (key === 'branch.head') {
|
||||
out.detached = value === '(detached)';
|
||||
out.branch = out.detached ? null : value;
|
||||
} else if (key === 'branch.upstream') {
|
||||
out.upstream = value;
|
||||
} else if (key === 'branch.ab') {
|
||||
const m = /^\+(\d+) -(\d+)$/.exec(value);
|
||||
if (m) {
|
||||
out.ahead = Number(m[1]);
|
||||
out.behind = Number(m[2]);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const type = t[0];
|
||||
if (type === '1') {
|
||||
// 1 XY sub mH mI mW hH hI path
|
||||
const f = t.split(' ');
|
||||
const xy = f[1] ?? '..';
|
||||
out.files.push(...entriesFor(xy, f.slice(8).join(' ')));
|
||||
} else if (type === '2') {
|
||||
// 2 XY sub mH mI mW hH hI Xscore path <NUL> origPath
|
||||
const f = t.split(' ');
|
||||
const xy = f[1] ?? '..';
|
||||
const path = f.slice(9).join(' ');
|
||||
const origPath = tokens[++i] ?? '';
|
||||
out.files.push(...entriesFor(xy, path, origPath));
|
||||
} else if (type === 'u') {
|
||||
// u XY sub m1 m2 m3 mW h1 h2 h3 path
|
||||
const f = t.split(' ');
|
||||
out.files.push({
|
||||
path: f.slice(10).join(' '),
|
||||
index: f[1]?.[0] ?? 'U',
|
||||
worktree: f[1]?.[1] ?? 'U',
|
||||
kind: 'conflicted',
|
||||
});
|
||||
} else if (type === '?') {
|
||||
out.files.push({ path: t.slice(2), index: '?', worktree: '?', kind: 'untracked' });
|
||||
}
|
||||
// '!' (ignored) is not requested; anything unknown is skipped rather than guessed at.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One porcelain entry can be both staged AND modified in the tree: that is two rows, one per kind. */
|
||||
function entriesFor(xy: string, path: string, origPath?: string): GitFileEntry[] {
|
||||
const index = xy[0] ?? '.';
|
||||
const worktree = xy[1] ?? '.';
|
||||
const rows: GitFileEntry[] = [];
|
||||
const base = origPath ? { path, origPath } : { path };
|
||||
if (index !== '.') rows.push({ ...base, index, worktree, kind: 'staged' });
|
||||
if (worktree !== '.') rows.push({ ...base, index, worktree, kind: 'unstaged' });
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Parse `git log --format=%h%x1f%an%x1f%ct%x1f%s%x1e`. */
|
||||
export function parseCommitLog(text: string): GitCommitEntry[] {
|
||||
const out: GitCommitEntry[] = [];
|
||||
for (const record of text.split('\x1e')) {
|
||||
const r = record.replace(/^\n+/, '');
|
||||
if (!r) continue;
|
||||
const [hash, author, time, ...subject] = r.split('\x1f');
|
||||
if (!hash) continue;
|
||||
out.push({ hash, author: author ?? '', time: Number(time) || 0, subject: subject.join('\x1f') });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IO
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Runs `git <args>` in `cwd` and returns stdout. Injected so the cache and error paths test without git. */
|
||||
export type GitRunner = (cwd: string, args: string[]) => Promise<string>;
|
||||
|
||||
export const runGit: GitRunner = async (cwd, args) => {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
||||
// consult a filesystem monitor on behalf of a poll.
|
||||
['--no-optional-locks', '-c', 'core.fsmonitor=false', ...args],
|
||||
{
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: MAX_OUTPUT_BYTES,
|
||||
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
|
||||
}
|
||||
);
|
||||
return stdout;
|
||||
};
|
||||
|
||||
function describeFailure(err: unknown): { notARepo: boolean; message: string } {
|
||||
const e = err as { code?: unknown; stderr?: unknown; message?: string };
|
||||
const stderr = typeof e.stderr === 'string' ? e.stderr : '';
|
||||
if (/not a git repository/i.test(stderr)) return { notARepo: true, message: '' };
|
||||
if (e.code === 'ENOENT') return { notARepo: false, message: 'git is not installed (or the folder no longer exists)' };
|
||||
if (e.code === 'ETIMEDOUT' || (err as { killed?: boolean }).killed)
|
||||
return { notARepo: false, message: 'git timed out' };
|
||||
const text = (stderr || e.message || 'git failed').trim().split('\n')[0];
|
||||
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
|
||||
}
|
||||
|
||||
async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus> {
|
||||
let statusText: string;
|
||||
try {
|
||||
statusText = await git(cwd, [
|
||||
'status',
|
||||
'--porcelain=v2',
|
||||
'--branch',
|
||||
'-z',
|
||||
'--untracked-files=normal',
|
||||
'--ignore-submodules=dirty',
|
||||
]);
|
||||
} catch (err) {
|
||||
const f = describeFailure(err);
|
||||
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
|
||||
}
|
||||
const parsed = parsePorcelainV2(statusText);
|
||||
|
||||
const safe = async (args: string[]): Promise<string> => {
|
||||
try {
|
||||
return await git(cwd, args);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const hasUpstream = parsed.upstream !== null;
|
||||
// With an upstream: what is ahead of it. Without one (a branch never pushed, or a detached HEAD):
|
||||
// what is on HEAD but on no remote-tracking ref at all.
|
||||
const range = hasUpstream ? ['@{upstream}..HEAD'] : ['HEAD', '--not', '--remotes'];
|
||||
const [root, remotes, stash, countText, logText] = await Promise.all([
|
||||
safe(['rev-parse', '--show-toplevel']),
|
||||
safe(['remote']),
|
||||
safe(['stash', 'list', '--format=%gd']),
|
||||
safe(['rev-list', '--count', ...range]),
|
||||
safe(['log', `--max-count=${MAX_COMMITS}`, '--format=%h%x1f%an%x1f%ct%x1f%s%x1e', ...range]),
|
||||
]);
|
||||
|
||||
const hasRemote = remotes.trim().length > 0;
|
||||
// A repository with no remote has nothing to push to, so "unpushed" would be every commit it has.
|
||||
const unpushedCount = hasUpstream || hasRemote ? Number(countText.trim()) || 0 : 0;
|
||||
const unpushed = unpushedCount > 0 ? parseCommitLog(logText) : [];
|
||||
|
||||
const counts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 };
|
||||
const distinct = new Set<string>();
|
||||
for (const f of parsed.files) {
|
||||
counts[f.kind]++;
|
||||
distinct.add(f.path);
|
||||
}
|
||||
counts.uncommitted = distinct.size;
|
||||
counts.stashes = stash.split('\n').filter(Boolean).length;
|
||||
|
||||
return {
|
||||
state: 'ok',
|
||||
repoRoot: root.trim() || undefined,
|
||||
branch: parsed.branch,
|
||||
detached: parsed.detached,
|
||||
upstream: parsed.upstream,
|
||||
ahead: parsed.ahead,
|
||||
behind: parsed.behind,
|
||||
hasRemote,
|
||||
counts,
|
||||
files: parsed.files.slice(0, MAX_FILES),
|
||||
filesTruncated: parsed.files.length > MAX_FILES,
|
||||
unpushedCount,
|
||||
unpushed,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
at: number;
|
||||
value?: GitWorkspaceStatus;
|
||||
inflight?: Promise<GitWorkspaceStatus>;
|
||||
}
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
/** For tests. */
|
||||
export function clearGitStatusCache(): void {
|
||||
cache.clear();
|
||||
discoveryCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* The git snapshot of `cwd`. Concurrent callers share one in-flight computation, and a result younger
|
||||
* than a few seconds is reused, so several tabs polling one repo cost one set of git processes.
|
||||
* `fresh` skips the reuse (a person pressed Refresh and expects the truth) but still joins a
|
||||
* computation that is already running, which is as current as a new one would be.
|
||||
*/
|
||||
export async function getGitWorkspaceStatus(
|
||||
cwd: string,
|
||||
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
|
||||
): Promise<GitWorkspaceStatus> {
|
||||
const git = opts.git ?? runGit;
|
||||
const now = opts.now ?? Date.now;
|
||||
const hit = cache.get(cwd);
|
||||
if (hit?.inflight) return hit.inflight;
|
||||
if (!opts.fresh && hit?.value && now() - hit.at < CACHE_TTL_MS) return hit.value;
|
||||
|
||||
const inflight = collect(cwd, git);
|
||||
cache.set(cwd, { at: now(), inflight });
|
||||
try {
|
||||
const value = await inflight;
|
||||
cache.set(cwd, { at: now(), value });
|
||||
if (cache.size > CACHE_MAX_ENTRIES) {
|
||||
for (const [k, v] of cache) {
|
||||
if (cache.size <= CACHE_MAX_ENTRIES) break;
|
||||
if (k !== cwd && !v.inflight) cache.delete(k);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
} catch (err) {
|
||||
cache.delete(cwd);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Which repositories: the overview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** How far below the working directory to look for repositories (`cwd/a/b` is found, `cwd/a/b/c` is not). */
|
||||
const DISCOVERY_MAX_DEPTH = 2;
|
||||
/** Directory entries inspected per folder, so a folder with thousands of children costs a bounded readdir. */
|
||||
const DISCOVERY_MAX_ENTRIES = 300;
|
||||
/** Repositories reported for one workspace. */
|
||||
export const MAX_REPOS = 12;
|
||||
/** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
|
||||
const DISCOVERY_TTL_MS = 30_000;
|
||||
/** Folders that are never worth descending into when looking for projects. */
|
||||
const DISCOVERY_SKIP = new Set(['node_modules', 'dist', 'build', 'target', '__pycache__', 'venv', 'vendor']);
|
||||
/** Status calls in flight at once for one overview: each is several git processes. */
|
||||
const STATUS_CONCURRENCY = 4;
|
||||
|
||||
export interface GitRepoEntry {
|
||||
/** Folder name of the repository (its root's basename). */
|
||||
name: string;
|
||||
/** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */
|
||||
path: string;
|
||||
status: GitWorkspaceStatus;
|
||||
}
|
||||
|
||||
export interface GitWorkspaceOverview {
|
||||
/** `ok` when at least one repository was found; the other states are as in `GitWorkspaceStatus`. */
|
||||
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||
reason?: 'remote' | 'docker';
|
||||
error?: string;
|
||||
repos: GitRepoEntry[];
|
||||
/** More than `MAX_REPOS` repositories were found; only the first are reported. */
|
||||
reposTruncated: boolean;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
export const emptyOverview = (
|
||||
state: GitWorkspaceOverview['state'],
|
||||
extra: Partial<GitWorkspaceOverview> = {}
|
||||
): GitWorkspaceOverview => ({ state, repos: [], reposTruncated: false, checkedAt: Date.now(), ...extra });
|
||||
|
||||
const realOr = async (p: string): Promise<string> => {
|
||||
try {
|
||||
return await fs.realpath(p);
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* True when `repoRoot` is a repository that merely contains the workspace and is the home folder or
|
||||
* above it (`$HOME` managed as a dotfiles repo, `/`, `/home`): its changes are not the session's work.
|
||||
* A workspace that IS the repository root is never "unrelated", even when that root is the home folder.
|
||||
*/
|
||||
export async function isUnrelatedAncestor(repoRoot: string, cwd: string, home: string): Promise<boolean> {
|
||||
const [root, here, h] = await Promise.all([realOr(repoRoot), realOr(cwd), realOr(home)]);
|
||||
if (root === here) return false;
|
||||
return root === sep || h === root || h.startsWith(root + sep);
|
||||
}
|
||||
|
||||
async function hasDotGit(dir: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(join(dir, '.git')); // a directory, or a file (worktrees and submodules)
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
|
||||
export async function discoverChildRepos(cwd: string): Promise<{ dirs: string[]; truncated: boolean }> {
|
||||
const found: string[] = [];
|
||||
let level = [cwd];
|
||||
for (let depth = 1; depth <= DISCOVERY_MAX_DEPTH && level.length > 0; depth++) {
|
||||
const next: string[] = [];
|
||||
for (const dir of level) {
|
||||
let entries;
|
||||
try {
|
||||
entries = (await fs.readdir(dir, { withFileTypes: true })).slice(0, DISCOVERY_MAX_ENTRIES);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
for (const e of entries) {
|
||||
// isDirectory() is false for a symlink, which is how a link to elsewhere is never followed.
|
||||
if (!e.isDirectory() || e.name.startsWith('.') || DISCOVERY_SKIP.has(e.name)) continue;
|
||||
const child = join(dir, e.name);
|
||||
if (await hasDotGit(child)) found.push(child);
|
||||
else next.push(child);
|
||||
}
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
return { dirs: found.slice(0, MAX_REPOS), truncated: found.length > MAX_REPOS };
|
||||
}
|
||||
|
||||
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
||||
|
||||
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */
|
||||
async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out: R[] = new Array(items.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < items.length) {
|
||||
const i = next++;
|
||||
out[i] = await fn(items[i]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything git knows about the session's workspace: the enclosing repository when there is one,
|
||||
* otherwise each repository found below the working directory. See the module header for the rules.
|
||||
*/
|
||||
export async function getGitWorkspaceOverview(
|
||||
cwd: string,
|
||||
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; home?: string } = {}
|
||||
): Promise<GitWorkspaceOverview> {
|
||||
const now = opts.now ?? Date.now;
|
||||
const primary = await getGitWorkspaceStatus(cwd, opts);
|
||||
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
|
||||
|
||||
const home = opts.home ?? homedir();
|
||||
if (primary.state === 'ok' && !(primary.repoRoot && (await isUnrelatedAncestor(primary.repoRoot, cwd, home)))) {
|
||||
const root = primary.repoRoot ?? cwd;
|
||||
return {
|
||||
state: 'ok',
|
||||
repos: [{ name: basename(root), path: relative(cwd, root) || '.', status: primary }],
|
||||
reposTruncated: false,
|
||||
checkedAt: primary.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Not inside a repository of this workspace: look below for projects.
|
||||
const hit = discoveryCache.get(cwd);
|
||||
let found: { dirs: string[]; truncated: boolean };
|
||||
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
|
||||
else {
|
||||
found = await discoverChildRepos(cwd);
|
||||
discoveryCache.set(cwd, { at: now(), value: found });
|
||||
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
|
||||
}
|
||||
const statuses = await mapLimited(found.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, opts));
|
||||
const repos: GitRepoEntry[] = [];
|
||||
found.dirs.forEach((dir, i) => {
|
||||
const status = statuses[i];
|
||||
if (status.state === 'ok') repos.push({ name: basename(dir), path: relative(cwd, dir), status });
|
||||
});
|
||||
if (!repos.length) return emptyOverview('not-a-repo');
|
||||
return { state: 'ok', repos, reposTruncated: found.truncated, checkedAt: Date.now() };
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* @fileoverview Git status indicator in the bottom bar, and the panel it opens.
|
||||
*
|
||||
* Agents leave work uncommitted and unpushed. This puts a small indicator at the right of the bottom
|
||||
* toolbar for the ACTIVE session's repository, or repositories when the session's folder holds several (`●3` uncommitted files, `↑2` commits not pushed,
|
||||
* `✓` when everything is committed and pushed) and, on click, a draggable panel in the style of the
|
||||
* Files window listing exactly which files are uncommitted and which commits are not pushed.
|
||||
*
|
||||
* OPTIONAL and per-device: `showGitStatus` (App Settings → Header & Panels → Bottom bar), default
|
||||
* OFF. While it is off nothing polls and the button never shows. While it is on, the page asks
|
||||
* `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the
|
||||
* session changes or the window regains focus). The route is read-only and offline: it never fetches
|
||||
* or changes the repository, so the "behind" number reflects the last `git fetch`, which the panel
|
||||
* footer says. Remote (SSH) and Docker sessions answer `unsupported` and show no indicator.
|
||||
*
|
||||
* Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is
|
||||
* only ever written with `textContent`, never `innerHTML`.
|
||||
*
|
||||
* The bottom toolbar's right group is hidden on phones (mobile.css), so this surface is desktop and
|
||||
* tablet only by construction.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.activeSessionId, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
|
||||
* @dependency panels-ui.js (openFilePreview)
|
||||
* @loadorder 12.57 of 16, after home-sessions.js, before entrance-animations.js
|
||||
*/
|
||||
|
||||
/** How often the active session's repository is re-read while the indicator is on. */
|
||||
const GIT_STATUS_POLL_MS = 15000;
|
||||
/** The timer only decides whether a poll is due; it is cheap and runs while the indicator is on. */
|
||||
const GIT_STATUS_TICK_MS = 2000;
|
||||
/** A focus or visibility change refreshes at once unless the last read is younger than this. */
|
||||
const GIT_STATUS_MIN_REFRESH_MS = 3000;
|
||||
|
||||
const GIT_STATUS_BADGE_TITLE = {
|
||||
M: 'Modified',
|
||||
A: 'Added',
|
||||
D: 'Deleted',
|
||||
R: 'Renamed',
|
||||
C: 'Copied',
|
||||
T: 'Type changed',
|
||||
U: 'Unmerged',
|
||||
'?': 'Untracked',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Per-device setting, default OFF. */
|
||||
isGitStatusEnabled() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
return (settings.showGitStatus ?? defaults.showGitStatus ?? false) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Starts or stops the poll to match the setting. Called from applyHeaderVisibilitySettings(), which
|
||||
* runs on boot and after every settings save, so a live toggle needs no reload.
|
||||
*/
|
||||
applyGitStatusVisibility() {
|
||||
const on = this.isGitStatusEnabled();
|
||||
if (on && !this._gitStatusTimer) {
|
||||
this._gitStatusTimer = setInterval(() => this._gitStatusTick(), GIT_STATUS_TICK_MS);
|
||||
this._gitStatusOnVisible = () => {
|
||||
if (!document.hidden) this.refreshGitStatus({ minAgeMs: GIT_STATUS_MIN_REFRESH_MS });
|
||||
};
|
||||
document.addEventListener('visibilitychange', this._gitStatusOnVisible);
|
||||
window.addEventListener('focus', this._gitStatusOnVisible);
|
||||
this.refreshGitStatus();
|
||||
} else if (!on && this._gitStatusTimer) {
|
||||
clearInterval(this._gitStatusTimer);
|
||||
this._gitStatusTimer = null;
|
||||
document.removeEventListener('visibilitychange', this._gitStatusOnVisible);
|
||||
window.removeEventListener('focus', this._gitStatusOnVisible);
|
||||
this._gitStatusOnVisible = null;
|
||||
}
|
||||
if (!on) {
|
||||
this._gitStatus = null;
|
||||
this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1; // an in-flight read must not repaint
|
||||
this.closeGitStatusPanel();
|
||||
}
|
||||
this._renderGitStatusButton();
|
||||
},
|
||||
|
||||
_gitStatusTick() {
|
||||
if (document.hidden) return;
|
||||
const sid = this.activeSessionId || null;
|
||||
if (sid !== this._gitStatusSessionId) {
|
||||
// The active session changed (or the first one opened): show nothing stale, read now.
|
||||
this._gitStatus = null;
|
||||
this._renderGitStatusButton();
|
||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel(); // not the previous repo's files
|
||||
this.refreshGitStatus();
|
||||
return;
|
||||
}
|
||||
if (sid && Date.now() - (this._gitStatusFetchedAt || 0) >= GIT_STATUS_POLL_MS) this.refreshGitStatus();
|
||||
},
|
||||
|
||||
/** Reads the active session's git status and repaints. Stale answers (another session, setting off) are dropped. */
|
||||
async refreshGitStatus({ minAgeMs = 0, fresh = false } = {}) {
|
||||
if (!this.isGitStatusEnabled()) return;
|
||||
const sid = this.activeSessionId || null;
|
||||
this._gitStatusSessionId = sid;
|
||||
if (!sid) {
|
||||
this._gitStatus = null;
|
||||
this._renderGitStatusButton();
|
||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
||||
return;
|
||||
}
|
||||
if (minAgeMs && Date.now() - (this._gitStatusFetchedAt || 0) < minAgeMs) return;
|
||||
// A read for THIS session is already running: let it finish. One for another session is not worth
|
||||
// waiting for (its answer is dropped below), so a session switch is never left blank.
|
||||
if (this._gitStatusInFlight && this._gitStatusInFlightSid === sid) return;
|
||||
this._gitStatusInFlight = true;
|
||||
this._gitStatusInFlightSid = sid;
|
||||
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
|
||||
this._gitStatusFetchedAt = Date.now();
|
||||
try {
|
||||
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status${fresh ? '?fresh=1' : ''}`);
|
||||
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
|
||||
this._gitStatus = data ? { sessionId: sid, data } : null;
|
||||
} catch {
|
||||
if (epoch === this._gitStatusEpoch) this._gitStatus = null;
|
||||
} finally {
|
||||
// Only the newest request owns the flag: an older one finishing late must not clear it.
|
||||
if (epoch === this._gitStatusEpoch) this._gitStatusInFlight = false;
|
||||
}
|
||||
if (epoch !== this._gitStatusEpoch) return;
|
||||
this._renderGitStatusButton();
|
||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
||||
},
|
||||
|
||||
/** The data for the session on screen, or null (not enabled, no session, not a repo, remote/docker, error). */
|
||||
_currentGitStatus() {
|
||||
const s = this._gitStatus;
|
||||
return s && s.sessionId === this.activeSessionId && s.data ? s.data : null;
|
||||
},
|
||||
|
||||
/** `{ uncommitted, unpushed, conflicted, repos, tone }` summed over every repository, or null when there is nothing to show. */
|
||||
_gitStatusSummary(overview) {
|
||||
if (!overview || overview.state !== 'ok' || !overview.repos?.length) return null;
|
||||
let uncommitted = 0;
|
||||
let unpushed = 0;
|
||||
let conflicted = 0;
|
||||
for (const r of overview.repos) {
|
||||
uncommitted += r.status.counts.uncommitted;
|
||||
unpushed += r.status.unpushedCount;
|
||||
conflicted += r.status.counts.conflicted;
|
||||
}
|
||||
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 ? 'dirty' : 'clean';
|
||||
return { uncommitted, unpushed, conflicted, repos: overview.repos.length, tone };
|
||||
},
|
||||
|
||||
/** One sentence for the tooltip and the screen-reader label. */
|
||||
_gitStatusSentence(overview) {
|
||||
const sum = this._gitStatusSummary(overview);
|
||||
if (!sum) return '';
|
||||
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
||||
const bits = [];
|
||||
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
|
||||
if (sum.uncommitted) bits.push(plural(sum.uncommitted, 'uncommitted file', 'uncommitted files'));
|
||||
if (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
|
||||
if (!bits.length) bits.push('everything is committed and pushed');
|
||||
let where;
|
||||
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
||||
else {
|
||||
const d = overview.repos[0].status;
|
||||
where = d.detached ? 'detached HEAD' : d.branch || 'no branch';
|
||||
}
|
||||
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
|
||||
},
|
||||
|
||||
_renderGitStatusButton() {
|
||||
const btn = this.$('gitStatusBtn');
|
||||
if (!btn) return;
|
||||
const data = this.isGitStatusEnabled() ? this._currentGitStatus() : null;
|
||||
const sum = this._gitStatusSummary(data);
|
||||
btn.hidden = !sum;
|
||||
btn.classList.toggle('git-status--clean', sum?.tone === 'clean');
|
||||
btn.classList.toggle('git-status--dirty', sum?.tone === 'dirty');
|
||||
btn.classList.toggle('git-status--conflict', sum?.tone === 'conflict');
|
||||
const label = btn.querySelector('.git-status-label');
|
||||
if (!sum) {
|
||||
if (label) label.textContent = '';
|
||||
return;
|
||||
}
|
||||
const parts = [];
|
||||
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
|
||||
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
||||
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
||||
if (!parts.length) parts.push('✓');
|
||||
if (label) label.textContent = parts.join(' ');
|
||||
const sentence = this._gitStatusSentence(data);
|
||||
btn.title = sentence;
|
||||
btn.setAttribute('aria-label', sentence);
|
||||
},
|
||||
|
||||
// ── Panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
_isGitStatusPanelOpen() {
|
||||
return !!this.$('gitStatusPanel')?.classList.contains('visible');
|
||||
},
|
||||
|
||||
toggleGitStatusPanel() {
|
||||
if (this._isGitStatusPanelOpen()) {
|
||||
this.closeGitStatusPanel();
|
||||
return;
|
||||
}
|
||||
const panel = this.$('gitStatusPanel');
|
||||
if (!panel) return;
|
||||
panel.classList.add('visible');
|
||||
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'true');
|
||||
this._ensureGitStatusPanelDrag();
|
||||
this._renderGitStatusPanel();
|
||||
this.refreshGitStatus({ fresh: true }); // the click should show what is true now, not what was true 14s ago
|
||||
},
|
||||
|
||||
closeGitStatusPanel() {
|
||||
const panel = this.$('gitStatusPanel');
|
||||
if (panel) {
|
||||
panel.classList.remove('visible');
|
||||
// Reset a dragged position so it reopens at the default spot.
|
||||
panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = '';
|
||||
}
|
||||
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
refreshGitStatusNow() {
|
||||
this._gitStatusFetchedAt = 0;
|
||||
this._gitStatusInFlight = false;
|
||||
return this.refreshGitStatus({ fresh: true });
|
||||
},
|
||||
|
||||
/** Drag by the header. Pointer events cover mouse, pen and touch; one set of listeners lives as long as the page. */
|
||||
_ensureGitStatusPanelDrag() {
|
||||
const panel = this.$('gitStatusPanel');
|
||||
const handle = panel?.querySelector('.git-status-header');
|
||||
if (!panel || !handle || handle._dragReady) return;
|
||||
handle._dragReady = true;
|
||||
let drag = null;
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
|
||||
// Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system.
|
||||
panel.style.left = `${rect.left}px`;
|
||||
panel.style.top = `${rect.top}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
handle.setPointerCapture?.(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const maxX = window.innerWidth - panel.offsetWidth - 4;
|
||||
const maxY = window.innerHeight - panel.offsetHeight - 4;
|
||||
panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`;
|
||||
panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`;
|
||||
});
|
||||
const end = (e) => {
|
||||
drag = null;
|
||||
handle.releasePointerCapture?.(e.pointerId);
|
||||
};
|
||||
handle.addEventListener('pointerup', end);
|
||||
handle.addEventListener('pointercancel', end);
|
||||
},
|
||||
|
||||
_gitEl(tag, className, text) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
},
|
||||
|
||||
_renderGitStatusPanel() {
|
||||
const body = this.$('gitStatusBody');
|
||||
const head = this.$('gitStatusBranch');
|
||||
const foot = this.$('gitStatusFooter');
|
||||
if (!body) return;
|
||||
const overview = this._currentGitStatus();
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
body.replaceChildren();
|
||||
const clearChrome = () => {
|
||||
if (head) head.textContent = '';
|
||||
if (foot) foot.textContent = '';
|
||||
};
|
||||
|
||||
if (!this.activeSessionId) {
|
||||
body.append(el('div', 'git-status-empty', 'Open a session to see its repository.'));
|
||||
clearChrome();
|
||||
return;
|
||||
}
|
||||
if (!overview) {
|
||||
body.append(
|
||||
el('div', 'git-status-empty', this._gitStatus === null ? 'Reading the repository…' : 'No status available.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (overview.state !== 'ok') {
|
||||
const why =
|
||||
overview.state === 'not-a-repo'
|
||||
? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).'
|
||||
: overview.state === 'unsupported'
|
||||
? `Git status is not available for ${overview.reason === 'docker' ? 'Docker' : 'remote (SSH)'} sessions.`
|
||||
: `Could not read the repository: ${overview.error || 'git failed'}`;
|
||||
body.append(el('div', 'git-status-empty', why));
|
||||
clearChrome();
|
||||
return;
|
||||
}
|
||||
|
||||
const repos = overview.repos;
|
||||
if (repos.length === 1) {
|
||||
// One repository: the panel is that repository, as it always was.
|
||||
const d = repos[0].status;
|
||||
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
|
||||
this._renderGitRepoInto(body, d);
|
||||
} else {
|
||||
if (head) head.textContent = `${repos.length} repositories`;
|
||||
for (const r of repos) body.append(this._gitRepoSection(r));
|
||||
if (overview.reposTruncated) {
|
||||
body.append(
|
||||
el('div', 'git-status-more', `Showing the first ${repos.length} repositories found under this folder.`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (foot) {
|
||||
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only: Codeman never fetches or changes the repository, so “behind” is as of your last fetch.`;
|
||||
}
|
||||
},
|
||||
|
||||
/** One repository of several: a collapsible section, open when it has something outstanding. */
|
||||
_gitRepoSection(r) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
const d = r.status;
|
||||
const section = el('details', 'git-status-repo');
|
||||
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
||||
section.open = outstanding;
|
||||
const summary = el('summary', 'git-status-repo-summary');
|
||||
summary.append(el('span', 'git-status-repo-name', r.name));
|
||||
if (r.path !== r.name) summary.append(el('span', 'git-status-repo-path', r.path));
|
||||
summary.append(el('span', 'git-status-repo-branch', d.detached ? 'detached HEAD' : d.branch || ''));
|
||||
const bits = [];
|
||||
if (d.counts.conflicted) bits.push(`⚠ ${d.counts.conflicted}`);
|
||||
if (d.counts.uncommitted) bits.push(`● ${d.counts.uncommitted}`);
|
||||
if (d.unpushedCount) bits.push(`↑ ${d.unpushedCount}`);
|
||||
const state = el(
|
||||
'span',
|
||||
`git-status-repo-state${outstanding ? ' git-status-repo-state--dirty' : ''}`,
|
||||
bits.join(' ') || '✓'
|
||||
);
|
||||
summary.append(state);
|
||||
section.append(summary);
|
||||
const inner = el('div', 'git-status-repo-body');
|
||||
this._renderGitRepoInto(inner, d);
|
||||
section.append(inner);
|
||||
return section;
|
||||
},
|
||||
|
||||
/** The branch line, uncommitted files and unpushed commits of ONE repository into `body`. */
|
||||
_renderGitRepoInto(body, data) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
|
||||
// Branch / upstream line.
|
||||
const line = el('div', 'git-status-branchline');
|
||||
if (data.upstream) {
|
||||
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
|
||||
if (data.ahead) line.append(el('span', 'git-status-chip git-status-chip--warn', `↑ ${data.ahead} ahead`));
|
||||
if (data.behind) {
|
||||
const behind = el('span', 'git-status-chip', `↓ ${data.behind} behind`);
|
||||
behind.title = 'As of the last git fetch: Codeman never fetches.';
|
||||
line.append(behind);
|
||||
}
|
||||
} else if (data.hasRemote) {
|
||||
line.append(el('span', 'git-status-chip git-status-chip--warn', 'No upstream branch'));
|
||||
} else {
|
||||
line.append(el('span', 'git-status-chip', 'No remote configured'));
|
||||
}
|
||||
if (data.counts.stashes) {
|
||||
line.append(
|
||||
el('span', 'git-status-chip', `${data.counts.stashes} stash${data.counts.stashes === 1 ? '' : 'es'}`)
|
||||
);
|
||||
}
|
||||
body.append(line);
|
||||
|
||||
// Uncommitted changes.
|
||||
const filesSection = el('section', 'git-status-section');
|
||||
filesSection.append(el('h4', 'git-status-section-title', `Uncommitted changes (${data.counts.uncommitted})`));
|
||||
if (!data.files.length) {
|
||||
filesSection.append(el('div', 'git-status-ok', 'Nothing uncommitted.'));
|
||||
} else {
|
||||
const groups = [
|
||||
['conflicted', 'Merge conflicts'],
|
||||
['staged', 'Staged'],
|
||||
['unstaged', 'Not staged'],
|
||||
['untracked', 'Untracked'],
|
||||
];
|
||||
for (const [kind, label] of groups) {
|
||||
const rows = data.files.filter((f) => f.kind === kind);
|
||||
if (!rows.length) continue;
|
||||
const group = el('div', `git-status-group git-status-group--${kind}`);
|
||||
group.append(el('div', 'git-status-group-title', `${label} (${data.counts[kind]})`));
|
||||
for (const f of rows) group.append(this._gitFileRow(f, data));
|
||||
filesSection.append(group);
|
||||
}
|
||||
if (data.filesTruncated) {
|
||||
filesSection.append(
|
||||
el(
|
||||
'div',
|
||||
'git-status-more',
|
||||
`Showing the first ${data.files.length} entries; the counts above include every file.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
body.append(filesSection);
|
||||
|
||||
// Commits not pushed.
|
||||
const pushSection = el('section', 'git-status-section');
|
||||
pushSection.append(el('h4', 'git-status-section-title', `Not pushed (${data.unpushedCount})`));
|
||||
if (!data.unpushedCount) {
|
||||
pushSection.append(
|
||||
el(
|
||||
'div',
|
||||
'git-status-ok',
|
||||
data.hasRemote ? 'Every commit on this branch is on a remote.' : 'There is no remote to push to.'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
if (!data.upstream) {
|
||||
pushSection.append(
|
||||
el('div', 'git-status-note', 'This branch has no upstream, so these commits are on no remote yet.')
|
||||
);
|
||||
}
|
||||
for (const c of data.unpushed) pushSection.append(this._gitCommitRow(c));
|
||||
if (data.unpushedCount > data.unpushed.length) {
|
||||
pushSection.append(
|
||||
el('div', 'git-status-more', `…and ${data.unpushedCount - data.unpushed.length} older commits.`)
|
||||
);
|
||||
}
|
||||
}
|
||||
body.append(pushSection);
|
||||
},
|
||||
|
||||
_gitFileRow(f, data) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
const row = el('div', 'git-status-file');
|
||||
// Untracked entries have `?`; staged ones show the index letter, the rest the working-tree letter.
|
||||
const letter =
|
||||
f.kind === 'untracked' ? '?' : f.kind === 'conflicted' ? 'U' : f.kind === 'staged' ? f.index : f.worktree;
|
||||
const badge = el('span', `git-status-badge git-status-badge--${letter === '?' ? 'new' : letter}`, letter);
|
||||
badge.title = GIT_STATUS_BADGE_TITLE[letter] || letter;
|
||||
row.append(badge);
|
||||
const name = el('span', 'git-status-path', f.path);
|
||||
row.append(name);
|
||||
if (f.origPath) row.append(el('span', 'git-status-orig', `← ${f.origPath}`));
|
||||
|
||||
// Deleted files and untracked folders have nothing to preview.
|
||||
const previewable = letter !== 'D' && !f.path.endsWith('/') && data.repoRoot;
|
||||
if (previewable) {
|
||||
row.classList.add('git-status-file--clickable');
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('role', 'button');
|
||||
const open = () => this.openFilePreview?.(`${data.repoRoot}/${f.path}`, this.activeSessionId);
|
||||
row.addEventListener('click', open);
|
||||
row.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
}
|
||||
return row;
|
||||
},
|
||||
|
||||
_gitCommitRow(c) {
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
const row = el('div', 'git-status-commit');
|
||||
row.append(el('span', 'git-status-hash', c.hash));
|
||||
row.append(el('span', 'git-status-subject', c.subject));
|
||||
const meta = c.time ? `${c.author} · ${this.formatRelativeTime?.(c.time * 1000) ?? ''}` : c.author;
|
||||
row.append(el('span', 'git-status-commit-meta', meta));
|
||||
return row;
|
||||
},
|
||||
});
|
||||
@@ -567,6 +567,19 @@
|
||||
<div class="file-browser-status" id="fileBrowserStatus"></div>
|
||||
</div>
|
||||
|
||||
<!-- Git status panel (git-status-ui.js): what the active session's repo has not committed or pushed. -->
|
||||
<div class="git-status-panel" id="gitStatusPanel" role="dialog" aria-label="Git status">
|
||||
<div class="git-status-header">
|
||||
<span class="git-status-title">Git <span class="git-status-branch" id="gitStatusBranch" data-i18n-skip></span></span>
|
||||
<div class="git-status-actions">
|
||||
<button class="btn-icon-sm" onclick="app.refreshGitStatusNow()" title="Refresh" aria-label="Refresh git status">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeGitStatusPanel()" title="Close" aria-label="Close git status">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="git-status-body" id="gitStatusBody" data-i18n-skip></div>
|
||||
<div class="git-status-footer" id="gitStatusFooter" data-i18n-skip></div>
|
||||
</div>
|
||||
|
||||
<!-- File Preview Overlay -->
|
||||
<div class="file-preview-overlay" id="filePreviewOverlay">
|
||||
<div class="file-preview-window">
|
||||
@@ -771,6 +784,13 @@
|
||||
<!-- Orchestrator button hidden until feature is ready -->
|
||||
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">⚙ Orchestrator</button> -->
|
||||
<button class="btn-toolbar btn-sm btn-cron btn-cron--hidden" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<!-- Git status of the active session's repository (git-status-ui.js). Optional and per-device
|
||||
(App Settings → Header & Panels → Bottom bar, default OFF), so it is hidden until that
|
||||
setting is on AND the session is a local git repository. -->
|
||||
<button type="button" class="btn-toolbar btn-sm btn-git-status" id="gitStatusBtn" hidden aria-expanded="false" aria-controls="gitStatusPanel" onclick="app.toggleGitStatusPanel()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>
|
||||
<span class="git-status-label" data-i18n-skip></span>
|
||||
</button>
|
||||
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1935,6 +1955,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Bottom bar</h4></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Git status <span class="set-scope">device</span></span>
|
||||
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Subagent windows</h4></div>
|
||||
<div class="set-group-body">
|
||||
@@ -3870,6 +3903,7 @@
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="home-sessions.js"></script>
|
||||
<script defer src="git-status-ui.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -450,6 +450,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -2422,6 +2423,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -2674,6 +2676,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: _ssb,
|
||||
showAwayDigestButton: _adb,
|
||||
showCronButton: _crb,
|
||||
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
||||
showGitStatus: _sgs,
|
||||
showTabDetachButton: _tdb,
|
||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||
mobileOverviewEnabled: _mov,
|
||||
@@ -3613,6 +3617,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
|
||||
}
|
||||
|
||||
// Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to
|
||||
// match the setting, so a live toggle needs no reload.
|
||||
this.applyGitStatusVisibility?.();
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
@@ -3961,7 +3969,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection', 'copyStripMargin',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus',
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
'sessionLineageLines',
|
||||
|
||||
@@ -19274,3 +19274,304 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
/* ── Git status: bottom-bar indicator + panel (git-status-ui.js) ─────────────── */
|
||||
|
||||
/* .btn-toolbar sets display:flex, which beats the [hidden] attribute's UA display:none. */
|
||||
.btn-git-status[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-git-status {
|
||||
gap: 0.4rem;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-git-status .git-status-label {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* The skin block in styles.css nests every .btn-toolbar rule under html:not([data-skin="og"]), which
|
||||
out-ranks a bare .btn-git-status.<tone> rule, so the tones carry the same `html .toolbar` prefix
|
||||
(specificity (0,3,1) against the skin's (0,2,1)). Without it the amber never shows. */
|
||||
html .toolbar .btn-git-status.git-status--clean {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
html .toolbar .btn-git-status.git-status--clean svg {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
html .toolbar .btn-git-status.git-status--dirty {
|
||||
color: #e0a030;
|
||||
border-color: rgba(224, 160, 48, 0.4);
|
||||
}
|
||||
|
||||
html .toolbar .btn-git-status.git-status--conflict {
|
||||
color: #e5534b;
|
||||
border-color: rgba(229, 83, 75, 0.5);
|
||||
}
|
||||
|
||||
.btn-git-status[aria-expanded='true'] {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Same look as the Files window. Default spot is just left of it so both can be open. */
|
||||
.git-status-panel {
|
||||
position: fixed;
|
||||
top: calc(var(--header-height) + 10px);
|
||||
right: 320px;
|
||||
width: 380px;
|
||||
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
max-height: 600px;
|
||||
min-width: 260px;
|
||||
min-height: 220px;
|
||||
background: var(--floating-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 100;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.git-status-panel.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.git-status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.git-status-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-status-branch {
|
||||
margin-left: 0.4rem;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-status-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.git-status-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-status-footer {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.git-status-empty,
|
||||
.git-status-ok,
|
||||
.git-status-note,
|
||||
.git-status-more {
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.git-status-ok {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.git-status-branchline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.git-status-chip {
|
||||
padding: 0.1rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-status-chip--warn {
|
||||
color: #e0a030;
|
||||
border-color: rgba(224, 160, 48, 0.4);
|
||||
}
|
||||
|
||||
.git-status-section {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.git-status-section-title {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-status-group {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.git-status-group-title {
|
||||
font-size: 0.64rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.git-status-file,
|
||||
.git-status-commit {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border-radius: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-status-file--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.git-status-file--clickable:hover,
|
||||
.git-status-file--clickable:focus-visible {
|
||||
background: var(--bg-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.git-status-badge {
|
||||
flex: 0 0 1.1rem;
|
||||
text-align: center;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
color: #e0a030;
|
||||
}
|
||||
|
||||
.git-status-badge--A,
|
||||
.git-status-badge--new {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.git-status-badge--D,
|
||||
.git-status-badge--U {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
.git-status-path,
|
||||
.git-status-subject {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.git-status-subject {
|
||||
font-family: inherit;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.git-status-orig,
|
||||
.git-status-commit-meta {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-status-hash {
|
||||
flex: 0 0 auto;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.66rem;
|
||||
color: #e0a030;
|
||||
}
|
||||
|
||||
/* One section per repository when a session's folder holds several (git-status-ui.js). */
|
||||
.git-status-repo {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.git-status-repo-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
cursor: pointer;
|
||||
background: var(--bg-input);
|
||||
list-style: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-status-repo-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.git-status-repo-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.74rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.git-status-repo-path,
|
||||
.git-status-repo-branch {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.git-status-repo-state {
|
||||
margin-left: auto;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.git-status-repo-state--dirty {
|
||||
color: #e0a030;
|
||||
}
|
||||
|
||||
.git-status-repo-body {
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @fileoverview `GET /api/sessions/:id/git-status`: what the session's workspace has not committed or
|
||||
* pushed (src/git-workspace-status.ts), for the bottom-bar Git indicator and its panel. The answer is
|
||||
* an overview: the enclosing repository, or each repository found below a folder that holds several
|
||||
* projects (see `getGitWorkspaceOverview` for exactly which).
|
||||
*
|
||||
* Read-only and offline: it never fetches and never runs a git write command. A remote (SSH) or
|
||||
* Docker session is not inspected and answers `state: 'unsupported'`: a Docker workspace is writable
|
||||
* from inside the sandbox, and git here would run on the host. Ownership goes through
|
||||
* `findSessionOrFail`, like every session-scoped route.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { ApiResponse } from '../../types.js';
|
||||
import { findSessionOrFail } from '../route-helpers.js';
|
||||
import {
|
||||
emptyOverview,
|
||||
getGitWorkspaceOverview,
|
||||
type GitRunner,
|
||||
type GitWorkspaceOverview,
|
||||
} from '../../git-workspace-status.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
export function registerGitStatusRoutes(app: FastifyInstance, ctx: SessionPort, git?: GitRunner): void {
|
||||
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { fresh } = req.query as { fresh?: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
|
||||
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
|
||||
return { success: true, data: await getGitWorkspaceOverview(session.workingDir, { git, fresh: fresh === '1' }) };
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export { registerHookEventRoutes } from './hook-event-routes.js';
|
||||
export { registerApprovalRoutes } from './approval-routes.js';
|
||||
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
|
||||
export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
||||
export { registerGitStatusRoutes } from './git-status-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
export { registerSessionRoutes } from './session-routes.js';
|
||||
|
||||
@@ -183,6 +183,7 @@ import {
|
||||
registerApprovalRoutes,
|
||||
registerRebootRestoreRoutes,
|
||||
registerReadMyMindRoutes,
|
||||
registerGitStatusRoutes,
|
||||
registerStatusTelemetryRoutes,
|
||||
registerSystemRoutes,
|
||||
registerCaseRoutes,
|
||||
@@ -1121,6 +1122,7 @@ export class WebServer extends EventEmitter {
|
||||
registerApprovalRoutes(this.app, ctx);
|
||||
registerRebootRestoreRoutes(this.app, ctx);
|
||||
registerReadMyMindRoutes(this.app, ctx);
|
||||
registerGitStatusRoutes(this.app, ctx);
|
||||
registerStatusTelemetryRoutes(this.app, ctx);
|
||||
registerSystemRoutes(this.app, ctx);
|
||||
registerCaseRoutes(this.app, ctx);
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/** @fileoverview Bottom-bar Git indicator and panel, end to end: real server, real Chromium, a real git repo with a remote. */
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3192;
|
||||
const ENV = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'T',
|
||||
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||
GIT_COMMITTER_NAME: 'T',
|
||||
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
};
|
||||
const git = (cwd: string, ...args: string[]) =>
|
||||
execFileSync('git', ['-c', 'protocol.file.allow=always', ...args], { cwd, env: ENV, stdio: 'ignore' });
|
||||
|
||||
describe('Git status indicator in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let root: string;
|
||||
let repo: string;
|
||||
let plain: string;
|
||||
let repoSession: string;
|
||||
let plainSession: string;
|
||||
const gitStatusRequests: string[] = [];
|
||||
const settingsPutStatuses: number[] = [];
|
||||
|
||||
const write = (rel: string, text = 'x\n') => writeFileSync(join(repo, rel), text);
|
||||
const commitAll = (msg: string) => {
|
||||
git(repo, 'add', '-A');
|
||||
git(repo, 'commit', '-q', '-m', msg);
|
||||
};
|
||||
const label = () => page.evaluate(() => document.querySelector('#gitStatusBtn .git-status-label')?.textContent ?? '');
|
||||
const buttonVisible = () => page.evaluate(() => !document.getElementById('gitStatusBtn')!.hidden);
|
||||
const refresh = () => page.evaluate(() => (window as any).app.refreshGitStatusNow());
|
||||
const setSetting = async (on: boolean) => {
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
if ((await page.isChecked('#appSettingsShowGitStatus')) !== on)
|
||||
await page.click('label.switch:has(#appSettingsShowGitStatus)');
|
||||
await page.evaluate(() => (window as any).app.saveAppSettings());
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => (window as any).app.closeAppSettings());
|
||||
};
|
||||
const createSession = (dir: string) =>
|
||||
page.evaluate(async (workingDir) => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id as string;
|
||||
}, dir);
|
||||
const select = (id: string) =>
|
||||
page.evaluate(async (sid) => {
|
||||
const app = (window as any).app;
|
||||
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
|
||||
await app.selectSession(sid);
|
||||
}, id);
|
||||
|
||||
beforeAll(async () => {
|
||||
root = mkdtempSync(join(homedir(), 'git-ui-'));
|
||||
const bare = join(root, 'origin.git');
|
||||
repo = join(root, 'repo');
|
||||
plain = join(root, 'plain');
|
||||
mkdirSync(repo);
|
||||
mkdirSync(plain);
|
||||
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
|
||||
git(repo, 'init', '-q', '-b', 'main');
|
||||
git(repo, 'remote', 'add', 'origin', bare);
|
||||
write('a.txt', '1\n');
|
||||
commitAll('base');
|
||||
git(repo, 'push', '-q', '-u', 'origin', 'main');
|
||||
// Dirty: a modified file, an untracked file, and one commit nobody has pushed.
|
||||
write('b.txt', 'b\n');
|
||||
commitAll('add b (not pushed)');
|
||||
write('a.txt', '2\n');
|
||||
write('new file.txt', 'n\n');
|
||||
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
page.on('request', (r) => {
|
||||
// The API route only: the page also loads /git-status-ui.js, whose URL contains the same words.
|
||||
if (/\/api\/sessions\/[^/]+\/git-status/.test(r.url())) gitStatusRequests.push(r.url());
|
||||
});
|
||||
page.on('response', (r) => {
|
||||
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
|
||||
});
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
repoSession = await createSession(repo);
|
||||
plainSession = await createSession(plain);
|
||||
await select(repoSession);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
it('is off by default: no button and no request to the git-status route', async () => {
|
||||
await page.waitForTimeout(2500); // longer than one tick
|
||||
expect(await buttonVisible()).toBe(false);
|
||||
expect(gitStatusRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it('turning it on through Settings saves cleanly (the key must not reach the strict PUT) and shows the counts', async () => {
|
||||
await setSetting(true);
|
||||
expect(settingsPutStatuses.length).toBeGreaterThan(0);
|
||||
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
|
||||
await page.waitForFunction(() => !document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
|
||||
expect(await label()).toContain('● 2'); // a.txt modified + new file.txt untracked
|
||||
expect(await label()).toContain('↑ 1'); // one commit not pushed
|
||||
const title = await page.getAttribute('#gitStatusBtn', 'title');
|
||||
expect(title).toMatch(/main/);
|
||||
expect(title).toMatch(/2 uncommitted files/);
|
||||
expect(title).toMatch(/1 commit not pushed/);
|
||||
expect(await page.getAttribute('#gitStatusBtn', 'class')).toMatch(/git-status--dirty/);
|
||||
// The toolbar skin rules out-rank a bare class, so check the colour the user actually sees.
|
||||
const colour = await page.evaluate(() => getComputedStyle(document.getElementById('gitStatusBtn')!).color);
|
||||
expect(colour).toBe('rgb(224, 160, 48)');
|
||||
});
|
||||
|
||||
it('sits at the right of the bottom bar, beside the version', async () => {
|
||||
const inRight = await page.evaluate(() => !!document.querySelector('.toolbar-right #gitStatusBtn'));
|
||||
expect(inRight).toBe(true);
|
||||
const order = await page.evaluate(() => {
|
||||
const right = document.querySelector('.toolbar-right')!;
|
||||
return [...right.children].map((c) => c.id).filter(Boolean);
|
||||
});
|
||||
expect(order.indexOf('gitStatusBtn')).toBeLessThan(order.indexOf('versionDisplay'));
|
||||
});
|
||||
|
||||
it('opens a panel listing the uncommitted files and the unpushed commit', async () => {
|
||||
await page.click('#gitStatusBtn');
|
||||
await page.waitForSelector('#gitStatusPanel.visible');
|
||||
await page.waitForFunction(() => /Not staged/.test(document.getElementById('gitStatusBody')!.textContent ?? ''));
|
||||
const body = (await page.textContent('#gitStatusBody')) ?? '';
|
||||
expect(body).toContain('main → origin/main');
|
||||
expect(body).toContain('Uncommitted changes (2)');
|
||||
expect(body).toMatch(/Not staged \(1\)/);
|
||||
expect(body).toMatch(/Untracked \(1\)/);
|
||||
expect(body).toContain('a.txt');
|
||||
expect(body).toContain('new file.txt');
|
||||
expect(body).toContain('Not pushed (1)');
|
||||
expect(body).toContain('add b (not pushed)');
|
||||
expect(await page.textContent('#gitStatusBranch')).toBe('main');
|
||||
expect(await page.textContent('#gitStatusFooter')).toMatch(/Read-only/);
|
||||
expect(await page.getAttribute('#gitStatusBtn', 'aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders hostile file names as text, never as markup', async () => {
|
||||
write('<img src=x onerror=window.__pwned=1>.txt');
|
||||
await refresh();
|
||||
await page.waitForFunction(() => /onerror/.test(document.getElementById('gitStatusBody')!.textContent ?? ''));
|
||||
expect(await page.$('#gitStatusBody img')).toBeNull();
|
||||
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clicking a file previews it by its absolute path; a deleted file is not clickable', async () => {
|
||||
await page.evaluate(() => {
|
||||
(window as any).__previewed = [];
|
||||
(window as any).app.openFilePreview = (p: string) => (window as any).__previewed.push(p);
|
||||
});
|
||||
await page.click('.git-status-file:has-text("a.txt")');
|
||||
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([join(repo, 'a.txt')]);
|
||||
rmSync(join(repo, 'b.txt'));
|
||||
await refresh();
|
||||
await page.waitForFunction(() =>
|
||||
/Deleted|b\.txt/.test(document.getElementById('gitStatusBody')!.textContent ?? '')
|
||||
);
|
||||
const deleted = page.locator('.git-status-file:has(.git-status-badge--D)');
|
||||
expect(await deleted.count()).toBe(1);
|
||||
expect(await deleted.first().getAttribute('role')).toBeNull();
|
||||
});
|
||||
|
||||
it('drags by the header', async () => {
|
||||
const before = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
|
||||
const box = (await page.locator('.git-status-header').boundingBox())!;
|
||||
await page.mouse.move(box.x + 40, box.y + 10);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
const after = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
|
||||
expect(after).toBeLessThan(before - 100);
|
||||
});
|
||||
|
||||
it('once everything is committed and pushed the button says so, and the panel agrees', async () => {
|
||||
rmSync(join(repo, '<img src=x onerror=window.__pwned=1>.txt'));
|
||||
git(repo, 'checkout', '-q', '--', '.');
|
||||
git(repo, 'clean', '-fdq');
|
||||
git(repo, 'push', '-q');
|
||||
await refresh();
|
||||
// A manual refresh bypasses the server's short cache, so this shows the push at once (not at the
|
||||
// next 15 s poll, which is what a cached answer would mean).
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('#gitStatusBtn .git-status-label')?.textContent ?? '').includes('✓'),
|
||||
null,
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(await page.getAttribute('#gitStatusBtn', 'class')).toMatch(/git-status--clean/);
|
||||
// Toolbar buttons animate colour changes, so wait for the transition rather than racing it.
|
||||
await page.waitForFunction(
|
||||
() => getComputedStyle(document.getElementById('gitStatusBtn')!).color !== 'rgb(224, 160, 48)'
|
||||
);
|
||||
const body = (await page.textContent('#gitStatusBody')) ?? '';
|
||||
expect(body).toContain('Nothing uncommitted.');
|
||||
expect(body).toContain('Every commit on this branch is on a remote.');
|
||||
});
|
||||
|
||||
it('shows nothing for a session whose folder is not a repository, and follows the active session', async () => {
|
||||
await select(plainSession);
|
||||
await page.waitForFunction(() => document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
|
||||
// The open panel must stop showing the previous repo at once, then say why there is nothing.
|
||||
await page.waitForFunction(
|
||||
() => /No git repository here/.test(document.getElementById('gitStatusBody')!.textContent ?? ''),
|
||||
null,
|
||||
{
|
||||
timeout: 15000,
|
||||
}
|
||||
);
|
||||
expect(await page.textContent('#gitStatusBody')).not.toContain('a.txt');
|
||||
await select(repoSession);
|
||||
await page.waitForFunction(() => !document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
|
||||
});
|
||||
|
||||
it('a folder that holds several repositories shows each one, and the indicator adds them up', async () => {
|
||||
const parent = join(root, 'monorepo-ish');
|
||||
mkdirSync(parent);
|
||||
for (const name of ['api', 'web']) {
|
||||
const r = join(parent, name);
|
||||
mkdirSync(r);
|
||||
git(r, 'init', '-q', '-b', 'main');
|
||||
writeFileSync(join(r, 'f.txt'), '1\n');
|
||||
git(r, 'add', '-A');
|
||||
git(r, 'commit', '-q', '-m', 'init');
|
||||
}
|
||||
writeFileSync(join(parent, 'api', 'dirty.txt'), 'x');
|
||||
writeFileSync(join(parent, 'api', 'dirty2.txt'), 'y');
|
||||
const multiSession = await createSession(parent);
|
||||
await select(multiSession);
|
||||
await page.waitForFunction(() => document.querySelectorAll('#gitStatusBody .git-status-repo').length === 2, null, {
|
||||
timeout: 15000,
|
||||
});
|
||||
expect(await page.textContent('#gitStatusBranch')).toBe('2 repositories');
|
||||
expect(await label()).toContain('● 2'); // only api is dirty, with two files
|
||||
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/Git \(2 repositories\)/);
|
||||
const names = await page.$$eval('.git-status-repo-name', (els) => els.map((e) => e.textContent));
|
||||
expect(names).toEqual(['api', 'web']);
|
||||
// The one with something outstanding is open; the clean one is collapsed.
|
||||
const open = await page.$$eval('.git-status-repo', (els) => els.map((e) => (e as HTMLDetailsElement).open));
|
||||
expect(open).toEqual([true, false]);
|
||||
const apiBody = (await page.textContent('.git-status-repo:nth-of-type(1)')) ?? '';
|
||||
expect(apiBody).toContain('dirty.txt');
|
||||
expect(apiBody).toContain('dirty2.txt');
|
||||
await select(repoSession);
|
||||
await page.waitForFunction(() => document.querySelectorAll('#gitStatusBody .git-status-repo').length === 0, null, {
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => {
|
||||
await page.click('.git-status-actions button[aria-label="Close git status"]');
|
||||
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
|
||||
await page.click('#gitStatusBtn');
|
||||
expect(await page.isVisible('#gitStatusPanel')).toBe(true);
|
||||
await setSetting(false);
|
||||
expect(await buttonVisible()).toBe(false);
|
||||
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
|
||||
expect(await page.evaluate(() => (window as any).app._gitStatusTimer)).toBeNull();
|
||||
const before = gitStatusRequests.length;
|
||||
await page.waitForTimeout(3000);
|
||||
expect(gitStatusRequests.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,662 @@
|
||||
// @vitest-environment node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
clearGitStatusCache,
|
||||
getGitWorkspaceStatus,
|
||||
MAX_FILES,
|
||||
parseCommitLog,
|
||||
parsePorcelainV2,
|
||||
type GitRunner,
|
||||
} from '../src/git-workspace-status.js';
|
||||
|
||||
const NUL = '\0';
|
||||
|
||||
describe('parsePorcelainV2', () => {
|
||||
const header = (extra: string[] = []) =>
|
||||
['# branch.oid abc123', '# branch.head main', '# branch.upstream origin/main', '# branch.ab +2 -1', ...extra].join(
|
||||
NUL
|
||||
) + NUL;
|
||||
|
||||
it('reads the branch, upstream and ahead/behind', () => {
|
||||
const p = parsePorcelainV2(header());
|
||||
expect(p).toMatchObject({
|
||||
branch: 'main',
|
||||
detached: false,
|
||||
upstream: 'origin/main',
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
files: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a detached HEAD and a branch with no upstream (no branch.ab line either)', () => {
|
||||
expect(parsePorcelainV2(['# branch.oid x', '# branch.head (detached)'].join(NUL) + NUL)).toMatchObject({
|
||||
branch: null,
|
||||
detached: true,
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
});
|
||||
expect(parsePorcelainV2(['# branch.oid x', '# branch.head feature'].join(NUL) + NUL)).toMatchObject({
|
||||
branch: 'feature',
|
||||
upstream: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('turns an entry that is staged AND modified in the tree into one row per kind', () => {
|
||||
const line = '1 MM N... 100644 100644 100644 aaa bbb src/a.ts';
|
||||
const files = parsePorcelainV2(header([line])).files;
|
||||
expect(files.map((f) => [f.kind, f.index, f.worktree, f.path])).toEqual([
|
||||
['staged', 'M', 'M', 'src/a.ts'],
|
||||
['unstaged', 'M', 'M', 'src/a.ts'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('classifies staged-only, unstaged-only, added and deleted', () => {
|
||||
const lines = [
|
||||
'1 M. N... 100644 100644 100644 a b staged.ts',
|
||||
'1 .M N... 100644 100644 100644 a b unstaged.ts',
|
||||
'1 A. N... 000000 100644 100644 0 b added.ts',
|
||||
'1 .D N... 100644 100644 000000 a b gone.ts',
|
||||
];
|
||||
const files = parsePorcelainV2(header(lines)).files;
|
||||
expect(files.map((f) => `${f.kind}:${f.index}${f.worktree}:${f.path}`)).toEqual([
|
||||
'staged:M.:staged.ts',
|
||||
'unstaged:.M:unstaged.ts',
|
||||
'staged:A.:added.ts',
|
||||
'unstaged:.D:gone.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a rename with its original path from the following token', () => {
|
||||
const text = header(['2 R. N... 100644 100644 100644 a b R100 new name.ts' + NUL + 'old name.ts']);
|
||||
expect(parsePorcelainV2(text).files).toEqual([
|
||||
{ path: 'new name.ts', origPath: 'old name.ts', index: 'R', worktree: '.', kind: 'staged' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads unmerged and untracked entries, and keeps odd names intact', () => {
|
||||
const text = header([
|
||||
'u UU N... 100644 100644 100644 100644 a b c conflict.ts',
|
||||
'? with space.txt',
|
||||
'? quote"and\'tick.txt',
|
||||
'? new\nline.txt',
|
||||
'? dir/',
|
||||
]);
|
||||
const files = parsePorcelainV2(text).files;
|
||||
expect(files.map((f) => [f.kind, f.path])).toEqual([
|
||||
['conflicted', 'conflict.ts'],
|
||||
['untracked', 'with space.txt'],
|
||||
['untracked', 'quote"and\'tick.txt'],
|
||||
['untracked', 'new\nline.txt'],
|
||||
['untracked', 'dir/'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips unknown lines and survives empty input', () => {
|
||||
expect(parsePorcelainV2('')).toMatchObject({ files: [], branch: null });
|
||||
expect(parsePorcelainV2('! ignored.log' + NUL + 'weird line' + NUL).files).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCommitLog', () => {
|
||||
it('reads hash, author, time and subject, including unicode and empty input', () => {
|
||||
const text = 'abc1234\x1fAda\x1f1700000000\x1ffix: café\x1e\ndef5678\x1fBob\x1f1700000100\x1fsecond\x1e';
|
||||
expect(parseCommitLog(text)).toEqual([
|
||||
{ hash: 'abc1234', author: 'Ada', time: 1700000000, subject: 'fix: café' },
|
||||
{ hash: 'def5678', author: 'Bob', time: 1700000100, subject: 'second' },
|
||||
]);
|
||||
expect(parseCommitLog('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real git
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GIT_ENV = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'T',
|
||||
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||
GIT_COMMITTER_NAME: 'T',
|
||||
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
};
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', ['-c', 'commit.gpgsign=false', '-c', 'protocol.file.allow=always', ...args], {
|
||||
cwd,
|
||||
env: GIT_ENV,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let root: string;
|
||||
let repo: string;
|
||||
const write = (rel: string, text = 'x\n', dir = repo) => {
|
||||
const f = join(dir, rel);
|
||||
mkdirSync(join(f, '..'), { recursive: true });
|
||||
writeFileSync(f, text);
|
||||
};
|
||||
const commit = (msg: string, dir = repo) => {
|
||||
git(dir, 'add', '-A');
|
||||
git(dir, 'commit', '-q', '-m', msg);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
clearGitStatusCache();
|
||||
root = mkdtempSync(join(tmpdir(), 'git-status-'));
|
||||
repo = join(root, 'repo');
|
||||
mkdirSync(repo);
|
||||
git(repo, 'init', '-q', '-b', 'main');
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('getGitWorkspaceStatus against a real repository', () => {
|
||||
it('is not-a-repo outside a repository', async () => {
|
||||
const plain = join(root, 'plain');
|
||||
mkdirSync(plain);
|
||||
expect((await getGitWorkspaceStatus(plain)).state).toBe('not-a-repo');
|
||||
});
|
||||
|
||||
it('reports a folder that no longer exists as not-a-repo or an error, never a crash', async () => {
|
||||
const s = await getGitWorkspaceStatus(join(root, 'gone'));
|
||||
expect(['not-a-repo', 'error']).toContain(s.state);
|
||||
});
|
||||
|
||||
it('a fresh repo: on its branch, untracked files, no remote, nothing "unpushed"', async () => {
|
||||
write('a.txt');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s).toMatchObject({ state: 'ok', branch: 'main', upstream: null, hasRemote: false, unpushedCount: 0 });
|
||||
expect(s.counts).toMatchObject({ untracked: 1, uncommitted: 1, staged: 0, unstaged: 0 });
|
||||
});
|
||||
|
||||
it('a clean repo with commits but no remote has nothing to push (not "every commit")', async () => {
|
||||
write('a.txt');
|
||||
commit('one');
|
||||
write('b.txt');
|
||||
commit('two');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s.counts.uncommitted).toBe(0);
|
||||
expect(s).toMatchObject({ hasRemote: false, unpushedCount: 0, unpushed: [] });
|
||||
});
|
||||
|
||||
it('separates staged, unstaged and untracked, and counts a staged+modified file once as uncommitted', async () => {
|
||||
write('tracked.txt', '1\n');
|
||||
write('both.txt', '1\n');
|
||||
write('removed.txt', '1\n');
|
||||
commit('base');
|
||||
write('both.txt', '2\n');
|
||||
git(repo, 'add', 'both.txt');
|
||||
write('both.txt', '3\n'); // staged AND modified again
|
||||
write('tracked.txt', '2\n'); // unstaged only
|
||||
git(repo, 'rm', '-q', 'removed.txt'); // staged delete
|
||||
write('new.txt'); // untracked
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s.counts).toMatchObject({ staged: 2, unstaged: 2, untracked: 1, conflicted: 0, uncommitted: 4 });
|
||||
const row = (kind: string, path: string) => s.files.find((f) => f.kind === kind && f.path === path);
|
||||
expect(row('staged', 'both.txt')).toBeTruthy();
|
||||
expect(row('unstaged', 'both.txt')).toBeTruthy();
|
||||
expect(row('staged', 'removed.txt')?.index).toBe('D');
|
||||
expect(row('untracked', 'new.txt')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reports a staged rename with its original path', async () => {
|
||||
write('old.txt', 'content that is long enough to be detected as a rename\n'.repeat(5));
|
||||
commit('base');
|
||||
git(repo, 'mv', 'old.txt', 'new.txt');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s.files).toContainEqual(
|
||||
expect.objectContaining({ path: 'new.txt', origPath: 'old.txt', index: 'R', kind: 'staged' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports merge conflicts', async () => {
|
||||
write('c.txt', 'base\n');
|
||||
commit('base');
|
||||
git(repo, 'checkout', '-q', '-b', 'other');
|
||||
write('c.txt', 'other\n');
|
||||
commit('other');
|
||||
git(repo, 'checkout', '-q', 'main');
|
||||
write('c.txt', 'main\n');
|
||||
commit('main');
|
||||
expect(() => git(repo, 'merge', 'other')).toThrow();
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s.counts.conflicted).toBe(1);
|
||||
expect(s.files.find((f) => f.kind === 'conflicted')?.path).toBe('c.txt');
|
||||
});
|
||||
|
||||
it('handles file names with spaces, quotes and unicode', async () => {
|
||||
write('with space.txt');
|
||||
write('quote"d.txt');
|
||||
write('café ☕.txt');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s.files.map((f) => f.path).sort()).toEqual(['café ☕.txt', 'quote"d.txt', 'with space.txt']);
|
||||
});
|
||||
|
||||
it('caps the file list but keeps the counts exact', async () => {
|
||||
for (let i = 0; i < MAX_FILES + 20; i++) write(`f${i}.txt`);
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s.files).toHaveLength(MAX_FILES);
|
||||
expect(s.filesTruncated).toBe(true);
|
||||
expect(s.counts.untracked).toBe(MAX_FILES + 20);
|
||||
expect(s.counts.uncommitted).toBe(MAX_FILES + 20);
|
||||
});
|
||||
|
||||
it('counts stashes', async () => {
|
||||
write('a.txt', '1\n');
|
||||
commit('base');
|
||||
write('a.txt', '2\n');
|
||||
git(repo, 'stash', '-q');
|
||||
expect((await getGitWorkspaceStatus(repo)).counts.stashes).toBe(1);
|
||||
});
|
||||
|
||||
it('reports a detached HEAD', async () => {
|
||||
write('a.txt');
|
||||
commit('one');
|
||||
git(repo, 'checkout', '-q', '--detach');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s).toMatchObject({ detached: true, branch: null });
|
||||
});
|
||||
|
||||
describe('with a remote', () => {
|
||||
let bare: string;
|
||||
beforeEach(() => {
|
||||
bare = join(root, 'origin.git');
|
||||
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
|
||||
git(repo, 'remote', 'add', 'origin', bare);
|
||||
write('a.txt', '1\n');
|
||||
commit('first');
|
||||
git(repo, 'push', '-q', '-u', 'origin', 'main');
|
||||
});
|
||||
|
||||
it('in sync: nothing ahead, nothing unpushed', async () => {
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s).toMatchObject({ upstream: 'origin/main', ahead: 0, behind: 0, hasRemote: true, unpushedCount: 0 });
|
||||
});
|
||||
|
||||
it('lists commits that are ahead of the upstream, newest first, with the subject and author', async () => {
|
||||
write('b.txt');
|
||||
commit('second: add b');
|
||||
write('c.txt');
|
||||
commit('third: add c');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s).toMatchObject({ ahead: 2, unpushedCount: 2 });
|
||||
expect(s.unpushed.map((c) => c.subject)).toEqual(['third: add c', 'second: add b']);
|
||||
expect(s.unpushed[0]).toMatchObject({ author: 'T' });
|
||||
expect(s.unpushed[0].hash).toMatch(/^[0-9a-f]{7,}$/);
|
||||
expect(s.unpushed[0].time).toBeGreaterThan(1_600_000_000);
|
||||
});
|
||||
|
||||
it('behind reflects the last fetch only: it never fetches on its own', async () => {
|
||||
const other = join(root, 'other');
|
||||
git(root, 'clone', '-q', bare, other);
|
||||
write('theirs.txt', 'x\n', other);
|
||||
commit('theirs', other);
|
||||
git(other, 'push', '-q', 'origin', 'main');
|
||||
expect((await getGitWorkspaceStatus(repo)).behind).toBe(0); // not fetched yet
|
||||
clearGitStatusCache();
|
||||
git(repo, 'fetch', '-q');
|
||||
expect((await getGitWorkspaceStatus(repo)).behind).toBe(1);
|
||||
});
|
||||
|
||||
it('a branch with no upstream lists what no remote has', async () => {
|
||||
git(repo, 'checkout', '-q', '-b', 'feature');
|
||||
write('f1.txt');
|
||||
commit('f1');
|
||||
write('f2.txt');
|
||||
commit('f2');
|
||||
const s = await getGitWorkspaceStatus(repo);
|
||||
expect(s).toMatchObject({ branch: 'feature', upstream: null, hasRemote: true, unpushedCount: 2 });
|
||||
expect(s.unpushed.map((c) => c.subject)).toEqual(['f2', 'f1']);
|
||||
});
|
||||
|
||||
it('a pushed branch is not reported as unpushed once it has an upstream', async () => {
|
||||
git(repo, 'checkout', '-q', '-b', 'feature');
|
||||
write('f1.txt');
|
||||
commit('f1');
|
||||
git(repo, 'push', '-q', '-u', 'origin', 'feature');
|
||||
expect((await getGitWorkspaceStatus(repo)).unpushedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('it only reads', () => {
|
||||
it('does not rewrite the index or leave a lock, even when stat data is stale', async () => {
|
||||
write('a.txt', '1\n');
|
||||
commit('base');
|
||||
const before = readFileSync(join(repo, '.git', 'index'));
|
||||
// Touching a tracked file makes a plain `git status` want to refresh the index.
|
||||
const now = new Date();
|
||||
const { utimesSync } = await import('node:fs');
|
||||
utimesSync(join(repo, 'a.txt'), now, new Date(now.getTime() + 5000));
|
||||
await getGitWorkspaceStatus(repo);
|
||||
expect(readFileSync(join(repo, '.git', 'index')).equals(before)).toBe(true);
|
||||
expect(existsSync(join(repo, '.git', 'index.lock'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not run a repository-configured fsmonitor hook (and a plain git status would have)', async () => {
|
||||
write('a.txt', '1\n');
|
||||
commit('base');
|
||||
const hook = join(root, 'fsmonitor.sh');
|
||||
const marker = join(root, 'fsmonitor-ran');
|
||||
writeFileSync(hook, `#!/bin/sh\necho ran >> '${marker}'\nprintf ''\n`);
|
||||
chmodSync(hook, 0o755);
|
||||
git(repo, 'config', 'core.fsmonitor', hook);
|
||||
// Control: git itself runs it for a plain status, so the assertion below is not vacuous.
|
||||
git(repo, 'status', '--short');
|
||||
expect(existsSync(marker)).toBe(true);
|
||||
rmSync(marker);
|
||||
await getGitWorkspaceStatus(repo);
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache and error mapping (fake runner)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('caching and failures', () => {
|
||||
const okRunner = (calls: string[][] = []): GitRunner =>
|
||||
vi.fn(async (_cwd, args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === 'status') return ['# branch.oid x', '# branch.head main'].join(NUL) + NUL;
|
||||
return '';
|
||||
});
|
||||
|
||||
it('never runs a git command that writes or touches the network', async () => {
|
||||
const calls: string[][] = [];
|
||||
await getGitWorkspaceStatus('/w/never-network', { git: okRunner(calls) });
|
||||
const verbs = new Set(calls.map((a) => a[0]));
|
||||
for (const forbidden of ['fetch', 'pull', 'push', 'commit', 'add', 'checkout', 'reset', 'clean', 'gc']) {
|
||||
expect(verbs.has(forbidden), forbidden).toBe(false);
|
||||
}
|
||||
expect([...verbs].sort()).toEqual(['log', 'remote', 'rev-list', 'rev-parse', 'stash', 'status']);
|
||||
});
|
||||
|
||||
it('shares one in-flight computation between concurrent callers', async () => {
|
||||
const calls: string[][] = [];
|
||||
const git = okRunner(calls);
|
||||
const [a, b, c] = await Promise.all([1, 2, 3].map(() => getGitWorkspaceStatus('/w/shared', { git })));
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
|
||||
expect(a).toBe(b);
|
||||
expect(b).toBe(c);
|
||||
});
|
||||
|
||||
it('reuses a fresh result, recomputes after the TTL, and keeps folders apart', async () => {
|
||||
let t = 1_000_000;
|
||||
const calls: string[][] = [];
|
||||
const git = okRunner(calls);
|
||||
const get = (cwd: string) => getGitWorkspaceStatus(cwd, { git, now: () => t });
|
||||
await get('/w/a');
|
||||
t += 1000;
|
||||
await get('/w/a');
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
|
||||
await get('/w/b');
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(2);
|
||||
t += 10_000;
|
||||
await get('/w/a');
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('`fresh` skips the reuse of a recent result, but still joins a computation already running', async () => {
|
||||
let t = 1_000_000;
|
||||
const calls: string[][] = [];
|
||||
const git = okRunner(calls);
|
||||
const get = (fresh: boolean) => getGitWorkspaceStatus('/w/fresh', { git, now: () => t, fresh });
|
||||
await get(false);
|
||||
t += 500;
|
||||
await get(false); // reused
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
|
||||
await get(true); // a person pressed Refresh
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(2);
|
||||
clearGitStatusCache();
|
||||
calls.length = 0;
|
||||
await Promise.all([get(true), get(true), get(true)]);
|
||||
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('maps "not a git repository" to not-a-repo', async () => {
|
||||
const git: GitRunner = async () => {
|
||||
throw Object.assign(new Error('x'), {
|
||||
stderr: 'fatal: not a git repository (or any of the parent directories): .git',
|
||||
});
|
||||
};
|
||||
expect((await getGitWorkspaceStatus('/w/none', { git })).state).toBe('not-a-repo');
|
||||
});
|
||||
|
||||
it('reports a missing git binary and a timeout as short errors', async () => {
|
||||
const enoent: GitRunner = async () => {
|
||||
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
|
||||
};
|
||||
expect(await getGitWorkspaceStatus('/w/e1', { git: enoent })).toMatchObject({
|
||||
state: 'error',
|
||||
error: expect.stringMatching(/not installed/),
|
||||
});
|
||||
const slow: GitRunner = async () => {
|
||||
throw Object.assign(new Error('timed out'), { killed: true });
|
||||
};
|
||||
expect(await getGitWorkspaceStatus('/w/e2', { git: slow })).toMatchObject({
|
||||
state: 'error',
|
||||
error: 'git timed out',
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts credentials embedded in a remote URL from an error message', async () => {
|
||||
const git: GitRunner = async () => {
|
||||
throw Object.assign(new Error('x'), {
|
||||
stderr: "fatal: unable to access 'https://user:ghp_SECRET@github.com/o/r.git/'",
|
||||
});
|
||||
};
|
||||
const s = await getGitWorkspaceStatus('/w/redact', { git });
|
||||
expect(s.state).toBe('error');
|
||||
expect(s.error).not.toContain('ghp_SECRET');
|
||||
expect(s.error).toContain('***:***@');
|
||||
});
|
||||
|
||||
it('survives the secondary calls failing: status still comes back', async () => {
|
||||
const git: GitRunner = async (_cwd, args) => {
|
||||
if (args[0] === 'status')
|
||||
return (
|
||||
['# branch.oid x', '# branch.head main', '# branch.upstream o/main', '# branch.ab +3 -0'].join(NUL) + NUL
|
||||
);
|
||||
throw new Error('boom');
|
||||
};
|
||||
const s = await getGitWorkspaceStatus('/w/partial', { git });
|
||||
expect(s).toMatchObject({ state: 'ok', branch: 'main', ahead: 3, unpushedCount: 0, unpushed: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Which repositories: getGitWorkspaceOverview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { mkdirSync as mkdir, symlinkSync as symlink } from 'node:fs';
|
||||
import {
|
||||
discoverChildRepos,
|
||||
getGitWorkspaceOverview,
|
||||
isUnrelatedAncestor,
|
||||
MAX_REPOS,
|
||||
} from '../src/git-workspace-status.js';
|
||||
|
||||
describe('getGitWorkspaceOverview', () => {
|
||||
let top: string;
|
||||
let home: string;
|
||||
const repoAt = (p: string): string => {
|
||||
mkdir(p, { recursive: true });
|
||||
git(p, 'init', '-q', '-b', 'main');
|
||||
writeFileSync(join(p, 'f.txt'), '1\n');
|
||||
git(p, 'add', '-A');
|
||||
git(p, 'commit', '-q', '-m', 'c');
|
||||
return p;
|
||||
};
|
||||
const names = (o: { repos: { path: string }[] }) => o.repos.map((r) => r.path);
|
||||
|
||||
beforeEach(() => {
|
||||
top = mkdtempSync(join(tmpdir(), 'git-overview-'));
|
||||
home = join(top, 'home');
|
||||
mkdir(home, { recursive: true });
|
||||
clearGitStatusCache();
|
||||
});
|
||||
afterEach(() => rmSync(top, { recursive: true, force: true }));
|
||||
|
||||
it('a folder that holds several repositories reports each, alphabetically, with its own status', async () => {
|
||||
const ws = join(home, 'case');
|
||||
repoAt(join(ws, 'web'));
|
||||
repoAt(join(ws, 'api'));
|
||||
writeFileSync(join(ws, 'api', 'dirty.txt'), 'x');
|
||||
const o = await getGitWorkspaceOverview(ws, { home });
|
||||
expect(o.state).toBe('ok');
|
||||
expect(names(o)).toEqual(['api', 'web']);
|
||||
expect(o.repos.map((r) => r.name)).toEqual(['api', 'web']);
|
||||
expect(o.repos[0].status.counts.uncommitted).toBe(1);
|
||||
expect(o.repos[1].status.counts.uncommitted).toBe(0);
|
||||
expect(o.reposTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('finds repositories two levels down but not three', async () => {
|
||||
const ws = join(home, 'case');
|
||||
repoAt(join(ws, 'apps', 'web'));
|
||||
repoAt(join(ws, 'a', 'b', 'too-deep'));
|
||||
const o = await getGitWorkspaceOverview(ws, { home });
|
||||
expect(names(o)).toEqual([join('apps', 'web')]);
|
||||
});
|
||||
|
||||
it('a subfolder of a repository reports the whole enclosing repository, naming where it is', async () => {
|
||||
const r = repoAt(join(home, 'proj'));
|
||||
mkdir(join(r, 'src', 'deep'), { recursive: true });
|
||||
writeFileSync(join(r, 'top.txt'), 'x');
|
||||
const o = await getGitWorkspaceOverview(join(r, 'src', 'deep'), { home });
|
||||
expect(o.repos).toHaveLength(1);
|
||||
expect(o.repos[0]).toMatchObject({ name: 'proj', path: join('..', '..') });
|
||||
expect(o.repos[0].status.files.map((f) => f.path)).toContain('top.txt');
|
||||
});
|
||||
|
||||
it('inside a repository it does not go looking for nested ones (they are just an untracked folder to the outer repo)', async () => {
|
||||
const r = repoAt(join(home, 'outer'));
|
||||
repoAt(join(r, 'vendor-ish', 'inner'));
|
||||
const o = await getGitWorkspaceOverview(r, { home });
|
||||
expect(names(o)).toEqual(['.']);
|
||||
expect(o.repos[0].status.files.map((f) => f.path)).toEqual(['vendor-ish/']);
|
||||
});
|
||||
|
||||
it('a session started inside the nested repository reports that one', async () => {
|
||||
const r = repoAt(join(home, 'outer'));
|
||||
const inner = repoAt(join(r, 'vendor-ish', 'inner'));
|
||||
const o = await getGitWorkspaceOverview(inner, { home });
|
||||
expect(o.repos[0].name).toBe('inner');
|
||||
});
|
||||
|
||||
it('ignores a repository that merely sits above the workspace and is the home folder (a dotfiles repo)', async () => {
|
||||
repoAt(home);
|
||||
writeFileSync(join(home, 'zshrc'), 'x'); // dirty, and nothing to do with this session
|
||||
const ws = join(home, 'codeman-cases', 'my-case');
|
||||
mkdir(ws, { recursive: true });
|
||||
const none = await getGitWorkspaceOverview(ws, { home });
|
||||
expect(none.state).toBe('not-a-repo');
|
||||
// ...but a project below the workspace is still found.
|
||||
repoAt(join(ws, 'real-project'));
|
||||
clearGitStatusCache();
|
||||
const some = await getGitWorkspaceOverview(ws, { home, fresh: true });
|
||||
expect(names(some)).toEqual(['real-project']);
|
||||
});
|
||||
|
||||
it('ignores a repository above the home folder (including a repo at the very top)', async () => {
|
||||
repoAt(top); // contains `home`, so it is above it
|
||||
const ws = join(home, 'case');
|
||||
mkdir(ws, { recursive: true });
|
||||
expect((await getGitWorkspaceOverview(ws, { home })).state).toBe('not-a-repo');
|
||||
});
|
||||
|
||||
it('but a workspace that IS the repository root is the session’s repository, even when that is the home folder', async () => {
|
||||
repoAt(home);
|
||||
const o = await getGitWorkspaceOverview(home, { home });
|
||||
expect(o.state).toBe('ok');
|
||||
expect(o.repos[0].path).toBe('.');
|
||||
});
|
||||
|
||||
it('accepts an enclosing repository that is below the home folder, and one outside the home folder entirely', async () => {
|
||||
const r = repoAt(join(home, 'proj'));
|
||||
mkdir(join(r, 'sub'), { recursive: true });
|
||||
expect((await getGitWorkspaceOverview(join(r, 'sub'), { home })).state).toBe('ok');
|
||||
const elsewhere = repoAt(join(top, 'elsewhere', 'proj'));
|
||||
clearGitStatusCache();
|
||||
expect((await getGitWorkspaceOverview(elsewhere, { home })).state).toBe('ok');
|
||||
});
|
||||
|
||||
it('skips node_modules, dot-folders and symbolic links when looking for repositories', async () => {
|
||||
const ws = join(home, 'case');
|
||||
repoAt(join(ws, 'node_modules', 'pkg'));
|
||||
repoAt(join(ws, '.hidden', 'secret'));
|
||||
const outside = repoAt(join(top, 'outside'));
|
||||
mkdir(ws, { recursive: true });
|
||||
symlink(outside, join(ws, 'linked'));
|
||||
repoAt(join(ws, 'real'));
|
||||
const o = await getGitWorkspaceOverview(ws, { home });
|
||||
expect(names(o)).toEqual(['real']);
|
||||
});
|
||||
|
||||
it('counts a worktree (its .git is a file) as a repository', async () => {
|
||||
const main = repoAt(join(top, 'main-repo'));
|
||||
const ws = join(home, 'case');
|
||||
mkdir(ws, { recursive: true });
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'feature', join(ws, 'wt'));
|
||||
const o = await getGitWorkspaceOverview(ws, { home });
|
||||
expect(names(o)).toEqual(['wt']);
|
||||
expect(o.repos[0].status.branch).toBe('feature');
|
||||
});
|
||||
|
||||
it('caps how many repositories it reports and says so', async () => {
|
||||
const ws = join(home, 'case');
|
||||
for (let i = 0; i < MAX_REPOS + 3; i++) repoAt(join(ws, `p${String(i).padStart(2, '0')}`));
|
||||
const o = await getGitWorkspaceOverview(ws, { home });
|
||||
expect(o.repos).toHaveLength(MAX_REPOS);
|
||||
expect(o.reposTruncated).toBe(true);
|
||||
expect(names(o)[0]).toBe('p00');
|
||||
});
|
||||
|
||||
it('is not-a-repo when there is no repository here or below, and reports a git failure as an error', async () => {
|
||||
const ws = join(home, 'empty');
|
||||
mkdir(join(ws, 'a'), { recursive: true });
|
||||
expect((await getGitWorkspaceOverview(ws, { home })).state).toBe('not-a-repo');
|
||||
clearGitStatusCache(); // the answer above is cached for this folder
|
||||
const broken: GitRunner = async () => {
|
||||
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
|
||||
};
|
||||
expect(await getGitWorkspaceOverview(ws, { home, git: broken })).toMatchObject({ state: 'error', repos: [] });
|
||||
});
|
||||
|
||||
it('re-scans for repositories only every so often, unless a person asked for fresh', async () => {
|
||||
let t = 1_000_000;
|
||||
const ws = join(home, 'case');
|
||||
mkdir(ws, { recursive: true });
|
||||
repoAt(join(ws, 'one'));
|
||||
const get = (fresh = false) => getGitWorkspaceOverview(ws, { home, now: () => t, fresh });
|
||||
expect(names(await get())).toEqual(['one']);
|
||||
repoAt(join(ws, 'two'));
|
||||
t += 5000;
|
||||
expect(names(await get())).toEqual(['one']); // list reused
|
||||
expect(names(await get(true))).toEqual(['one', 'two']); // Refresh sees it
|
||||
t += 60_000;
|
||||
expect(names(await get())).toEqual(['one', 'two']); // and so does the next scan
|
||||
});
|
||||
|
||||
it('discoverChildRepos never reads below a repository it found', async () => {
|
||||
const ws = join(home, 'case');
|
||||
repoAt(join(ws, 'outer'));
|
||||
repoAt(join(ws, 'outer', 'inner'));
|
||||
expect((await discoverChildRepos(ws)).dirs.map((d) => d.slice(ws.length + 1))).toEqual(['outer']);
|
||||
});
|
||||
|
||||
it('isUnrelatedAncestor: home or above, but never the workspace root itself', async () => {
|
||||
mkdir(join(home, 'proj'), { recursive: true });
|
||||
expect(await isUnrelatedAncestor(home, join(home, 'proj'), home)).toBe(true);
|
||||
expect(await isUnrelatedAncestor(top, join(home, 'proj'), home)).toBe(true);
|
||||
expect(await isUnrelatedAncestor('/', join(home, 'proj'), home)).toBe(true);
|
||||
expect(await isUnrelatedAncestor(join(home, 'proj'), join(home, 'proj', 'x'), home)).toBe(false);
|
||||
expect(await isUnrelatedAncestor(home, home, home)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @fileoverview GET /api/sessions/:id/git-status: the git snapshot behind the bottom-bar Git
|
||||
* indicator. Real git for the happy path; an injected runner for the cases that must not run git at
|
||||
* all (remote and Docker sessions). Port: N/A (app.inject()).
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js';
|
||||
import { clearGitStatusCache, type GitRunner } from '../../src/git-workspace-status.js';
|
||||
|
||||
const ENV = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'T',
|
||||
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||
GIT_COMMITTER_NAME: 'T',
|
||||
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
};
|
||||
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, env: ENV, stdio: 'ignore' });
|
||||
|
||||
let dir: string;
|
||||
let session: Record<string, unknown>;
|
||||
|
||||
async function setup(opts: { git?: GitRunner; authUser?: { username: string; role: 'admin' | 'user' } } = {}) {
|
||||
const h = await createRouteTestHarness((app, ctx) => registerGitStatusRoutes(app, ctx, opts.git), {
|
||||
authUser: opts.authUser,
|
||||
});
|
||||
session = h.ctx._session as unknown as Record<string, unknown>;
|
||||
session.workingDir = dir;
|
||||
return h;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearGitStatusCache();
|
||||
dir = mkdtempSync(join(tmpdir(), 'git-status-route-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/git-status', () => {
|
||||
it('returns the snapshot of the session workspace in the success envelope', async () => {
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
writeFileSync(join(dir, 'a.txt'), '1\n');
|
||||
git(dir, 'add', '-A');
|
||||
git(dir, 'commit', '-q', '-m', 'base');
|
||||
writeFileSync(join(dir, 'a.txt'), '2\n');
|
||||
writeFileSync(join(dir, 'new.txt'), 'n\n');
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.state).toBe('ok');
|
||||
expect(body.data.repos).toHaveLength(1);
|
||||
const repo = body.data.repos[0];
|
||||
expect(repo).toMatchObject({ path: '.', status: { state: 'ok', branch: 'main' } });
|
||||
expect(repo.status.counts).toMatchObject({ unstaged: 1, untracked: 1, uncommitted: 2 });
|
||||
expect(repo.status.files.map((f: { path: string }) => f.path).sort()).toEqual(['a.txt', 'new.txt']);
|
||||
});
|
||||
|
||||
it('reports each repository found below a folder that holds several projects', async () => {
|
||||
for (const name of ['api', 'web']) {
|
||||
mkdirSync(join(dir, name));
|
||||
git(join(dir, name), 'init', '-q', '-b', 'main');
|
||||
}
|
||||
writeFileSync(join(dir, 'api', 'dirty.txt'), 'x');
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
const data = res.json().data;
|
||||
expect(data.state).toBe('ok');
|
||||
expect(data.repos.map((r: { name: string; path: string }) => [r.name, r.path])).toEqual([
|
||||
['api', 'api'],
|
||||
['web', 'web'],
|
||||
]);
|
||||
expect(data.repos[0].status.counts.uncommitted).toBe(1);
|
||||
expect(data.repos[1].status.counts.uncommitted).toBe(0);
|
||||
});
|
||||
|
||||
it('answers not-a-repo for a folder that is not a repository', async () => {
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(res.json().data.state).toBe('not-a-repo');
|
||||
});
|
||||
|
||||
it('404s an unknown session', async () => {
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/git-status' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('reuses a recent result for a poll, and recomputes for ?fresh=1', async () => {
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
const { app } = await setup({ git: runner });
|
||||
const statusCalls = () => runner.mock.calls.filter(([, args]) => args[0] === 'status').length;
|
||||
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(statusCalls()).toBe(1);
|
||||
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?fresh=1' });
|
||||
expect(statusCalls()).toBe(2);
|
||||
});
|
||||
|
||||
it('runs git in the session working directory', async () => {
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
const { app } = await setup({ git: runner });
|
||||
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(runner).toHaveBeenCalled();
|
||||
for (const [cwd] of runner.mock.calls) expect(cwd).toBe(dir);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['remote', { host: 'h', user: 'u' }],
|
||||
['docker', { container: 'c' }],
|
||||
])('does not run git for a %s session and says it is unsupported', async (kind, value) => {
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
const { app } = await setup({ git: runner });
|
||||
session[kind] = value;
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: kind });
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('multi-user: another user’s session is not found, and git is not run for it', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
const { app } = await setup({ git: runner, authUser: { username: 'bob', role: 'user' } });
|
||||
session.owner = 'alice';
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('multi-user: the owner and an admin can read it', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const runner = vi.fn<GitRunner>(async () => '');
|
||||
for (const authUser of [
|
||||
{ username: 'alice', role: 'user' as const },
|
||||
{ username: 'root', role: 'admin' as const },
|
||||
]) {
|
||||
clearGitStatusCache();
|
||||
const { app } = await setup({ git: runner, authUser });
|
||||
session.owner = 'alice';
|
||||
expect((await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' })).statusCode).toBe(
|
||||
200
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user