mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
Merge #543: configurable git status repository limit and git timeout, unreadable repositories stay listed
# Conflicts: # src/web/public/styles.css
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"aicodeman": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
The Git status window's limits are settings now. **Git status: max repositories** (App Settings → Header & Panels → Bottom bar, per device, 1 to 50, default 12) is how many repositories it lists when the session's folder holds several projects, and **Git status: git timeout** (5 to 120 seconds, default 30, was a fixed 10) is how long one git command may run. A repository git could not read (a timeout on a slow network share was the usual cause) used to be dropped without a word, which left a count like "first 11" under a limit of 12; it now stays in the list with the reason, shows as `? N` in the indicator instead of letting it read ✓, and the truncation line reads "Showing the first N of more than N repositories" and points at the setting. `GET /api/sessions/:id/git-status` and `/git-diff` take `maxRepos` and `timeout` (seconds) query parameters, clamped server-side, and the overview reports `repoLimit`.
|
||||||
@@ -771,11 +771,13 @@ Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes ou
|
|||||||
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
|
**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.
|
- 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.
|
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most `maxRepos` of them (default 12, 1 to 50; `reposTruncated` says when there were more and `repoLimit` is the limit that was applied). 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 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.
|
- 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 }`:
|
Both routes accept two optional query parameters, which the UI sends from its per-device settings and the server clamps again: `maxRepos` (1 to 50, default 12) and `timeout` (seconds one git command may run, 5 to 120, default 30). An empty or non-numeric value means the default. A repository whose `git status` fails (typically a timeout on a slow network share) is **kept in `repos[]`** with `status.state: 'error'` and the reason in `status.error`, not dropped, so it is visible that something is not being reported.
|
||||||
|
|
||||||
|
`data` is `{ state, repos, reposTruncated, repoLimit, 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:
|
- `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`.
|
`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`.
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ bottom bar, off by default and per device. It reads `● N` uncommitted files, `
|
|||||||
pushed, `⚠ N` merge conflicts, or `✓` when everything is committed and pushed. Click it for the
|
pushed, `⚠ N` merge conflicts, or `✓` when everything is committed and pushed. Click it for the
|
||||||
Git window; see [Working With Files](Working-With-Files#git-changes). **Git status: group files
|
Git window; see [Working With Files](Working-With-Files#git-changes). **Git status: group files
|
||||||
by folder** (per device, on by default) shows changed files under collapsed folders in that
|
by folder** (per device, on by default) shows changed files under collapsed folders in that
|
||||||
window; off lists every file by its full path.
|
window; off lists every file by its full path. **Git status: max repositories** (per device,
|
||||||
|
1 to 50, default 12) is how many repositories the window lists when a session's folder holds
|
||||||
|
several projects. **Git status: git timeout** (per device, 5 to 120 seconds, default 30) is how
|
||||||
|
long one git command may run before that repository is reported as unreadable; raise it for
|
||||||
|
repositories on a slow network share.
|
||||||
|
|
||||||
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
|
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
|
||||||
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
|
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
|
||||||
|
|||||||
@@ -187,7 +187,10 @@ Click it for a draggable window, in the style of the File Viewer:
|
|||||||
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
|
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
|
||||||
note instead, and a diff over 400 KB is cut short.
|
note instead, and a diff over 400 KB is cut short.
|
||||||
- A session folder that holds several projects gets one collapsible section per repository
|
- A session folder that holds several projects gets one collapsible section per repository
|
||||||
found up to two levels down. They all start collapsed (each summary line shows its branch and
|
found up to two levels down (up to **Git status: max repositories**, 12 by default; the window says
|
||||||
|
when there are more). A repository git could not read, typically a timeout on a slow network
|
||||||
|
share, is listed with the reason and counted as `? N` in the bottom-bar indicator, never silently
|
||||||
|
left out; the **git timeout** setting raises how long it waits. They all start collapsed (each summary line shows its branch and
|
||||||
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
|
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
|
||||||
in your home folder) is ignored.
|
in your home folder) is ignored.
|
||||||
|
|
||||||
|
|||||||
+73
-33
@@ -52,7 +52,10 @@ import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
const GIT_TIMEOUT_MS = 10_000;
|
/** How long one git command may run, unless the caller passes `timeoutMs` (a slow network share needs more). */
|
||||||
|
export const DEFAULT_GIT_TIMEOUT_MS = 30_000;
|
||||||
|
export const MIN_GIT_TIMEOUT_MS = 5_000;
|
||||||
|
export const MAX_GIT_TIMEOUT_MS = 120_000;
|
||||||
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
|
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
|
||||||
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||||
/** Max file rows returned. The counts stay exact. */
|
/** Max file rows returned. The counts stay exact. */
|
||||||
@@ -258,9 +261,9 @@ export function parseCommitLog(text: string): GitCommitEntry[] {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Runs `git <args>` in `cwd` and returns stdout. Injected so the cache and error paths test without git. */
|
/** 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 type GitRunner = (cwd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<string>;
|
||||||
|
|
||||||
export const runGit: GitRunner = async (cwd, args) => {
|
export const runGit: GitRunner = async (cwd, args, opts) => {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
'git',
|
'git',
|
||||||
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
||||||
@@ -269,7 +272,7 @@ export const runGit: GitRunner = async (cwd, args) => {
|
|||||||
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
|
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
|
||||||
{
|
{
|
||||||
cwd,
|
cwd,
|
||||||
timeout: GIT_TIMEOUT_MS,
|
timeout: opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
|
||||||
maxBuffer: MAX_OUTPUT_BYTES,
|
maxBuffer: MAX_OUTPUT_BYTES,
|
||||||
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
|
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
|
||||||
}
|
}
|
||||||
@@ -288,17 +291,14 @@ function describeFailure(err: unknown): { notARepo: boolean; message: string } {
|
|||||||
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
|
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus> {
|
async function collect(cwd: string, git: GitRunner, timeoutMs?: number): Promise<GitWorkspaceStatus> {
|
||||||
let statusText: string;
|
let statusText: string;
|
||||||
try {
|
try {
|
||||||
statusText = await git(cwd, [
|
statusText = await git(
|
||||||
'status',
|
cwd,
|
||||||
'--porcelain=v2',
|
['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal', '--ignore-submodules=dirty'],
|
||||||
'--branch',
|
{ timeoutMs }
|
||||||
'-z',
|
);
|
||||||
'--untracked-files=normal',
|
|
||||||
'--ignore-submodules=dirty',
|
|
||||||
]);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const f = describeFailure(err);
|
const f = describeFailure(err);
|
||||||
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
|
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
|
||||||
@@ -307,7 +307,7 @@ async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus>
|
|||||||
|
|
||||||
const safe = async (args: string[]): Promise<string> => {
|
const safe = async (args: string[]): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
return await git(cwd, args);
|
return await git(cwd, args, { timeoutMs });
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -415,10 +415,12 @@ async function singleFlight<T>(
|
|||||||
*/
|
*/
|
||||||
export async function getGitWorkspaceStatus(
|
export async function getGitWorkspaceStatus(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number } = {}
|
||||||
): Promise<GitWorkspaceStatus> {
|
): Promise<GitWorkspaceStatus> {
|
||||||
const git = opts.git ?? runGit;
|
const git = opts.git ?? runGit;
|
||||||
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () => collect(cwd, git));
|
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () =>
|
||||||
|
collect(cwd, git, opts.timeoutMs)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
|
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
|
||||||
@@ -427,12 +429,12 @@ const toplevelCache = new Map<string, CacheEntry<RepoToplevel>>();
|
|||||||
/** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
|
/** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
|
||||||
function enclosingRepoRoot(
|
function enclosingRepoRoot(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
opts: { git?: GitRunner; now?: () => number; fresh?: boolean }
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number }
|
||||||
): Promise<RepoToplevel> {
|
): Promise<RepoToplevel> {
|
||||||
const git = opts.git ?? runGit;
|
const git = opts.git ?? runGit;
|
||||||
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
|
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
|
||||||
try {
|
try {
|
||||||
const root = (await git(cwd, ['rev-parse', '--show-toplevel'])).trim();
|
const root = (await git(cwd, ['rev-parse', '--show-toplevel'], { timeoutMs: opts.timeoutMs })).trim();
|
||||||
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
|
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const f = describeFailure(err);
|
const f = describeFailure(err);
|
||||||
@@ -451,6 +453,16 @@ const DISCOVERY_MAX_DEPTH = 2;
|
|||||||
const DISCOVERY_MAX_ENTRIES = 300;
|
const DISCOVERY_MAX_ENTRIES = 300;
|
||||||
/** Repositories reported for one workspace. */
|
/** Repositories reported for one workspace. */
|
||||||
export const MAX_REPOS = 12;
|
export const MAX_REPOS = 12;
|
||||||
|
/** The most repositories a caller may ask for: each one costs several git processes per poll. */
|
||||||
|
export const MAX_REPOS_LIMIT = 50;
|
||||||
|
|
||||||
|
/** `value` as a whole number within [min, max], else `fallback`. For options that arrive as untrusted query strings. */
|
||||||
|
export function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
// An empty string is "not given", not 0 (Number('') is 0, which would clamp to the minimum).
|
||||||
|
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.trunc(n)));
|
||||||
|
}
|
||||||
/** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
|
/** 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;
|
const DISCOVERY_TTL_MS = 30_000;
|
||||||
/** Folders that are never worth descending into when looking for projects. */
|
/** Folders that are never worth descending into when looking for projects. */
|
||||||
@@ -472,8 +484,10 @@ export interface GitWorkspaceOverview {
|
|||||||
reason?: 'remote' | 'docker';
|
reason?: 'remote' | 'docker';
|
||||||
error?: string;
|
error?: string;
|
||||||
repos: GitRepoEntry[];
|
repos: GitRepoEntry[];
|
||||||
/** More than `MAX_REPOS` repositories were found; only the first are reported. */
|
/** More than `repoLimit` repositories were found; only the first are reported. */
|
||||||
reposTruncated: boolean;
|
reposTruncated: boolean;
|
||||||
|
/** The most repositories this overview would list (the caller's setting, or `MAX_REPOS`). */
|
||||||
|
repoLimit?: number;
|
||||||
checkedAt: number;
|
checkedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,7 +567,8 @@ async function readDirBounded(dir: string): Promise<import('node:fs').Dirent[] |
|
|||||||
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
|
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
|
||||||
export async function discoverChildRepos(
|
export async function discoverChildRepos(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
excludeRealRoots: string[] = []
|
excludeRealRoots: string[] = [],
|
||||||
|
maxRepos: number = MAX_REPOS
|
||||||
): Promise<{ dirs: string[]; truncated: boolean }> {
|
): Promise<{ dirs: string[]; truncated: boolean }> {
|
||||||
const found: string[] = [];
|
const found: string[] = [];
|
||||||
let level = [cwd];
|
let level = [cwd];
|
||||||
@@ -576,7 +591,7 @@ export async function discoverChildRepos(
|
|||||||
}
|
}
|
||||||
level = next;
|
level = next;
|
||||||
}
|
}
|
||||||
return { dirs: found.slice(0, MAX_REPOS), truncated: found.length > MAX_REPOS };
|
return { dirs: found.slice(0, maxRepos), truncated: found.length > maxRepos };
|
||||||
}
|
}
|
||||||
|
|
||||||
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
||||||
@@ -602,13 +617,28 @@ export interface GitOverviewOptions {
|
|||||||
home?: string;
|
home?: string;
|
||||||
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
|
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
|
||||||
dockerWorkspaces?: string[];
|
dockerWorkspaces?: string[];
|
||||||
|
/** How many repositories to report below a folder that is not itself a repository (1 to `MAX_REPOS_LIMIT`, default `MAX_REPOS`). */
|
||||||
|
maxRepos?: number;
|
||||||
|
/** How long one git command may run, in ms (`MIN_GIT_TIMEOUT_MS` to `MAX_GIT_TIMEOUT_MS`, default `DEFAULT_GIT_TIMEOUT_MS`). */
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The repository limit and git timeout an overview was computed with, from untrusted options. */
|
||||||
|
export function resolveOverviewLimits(opts: { maxRepos?: unknown; timeoutMs?: unknown }): {
|
||||||
|
maxRepos: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
maxRepos: clampInt(opts.maxRepos, 1, MAX_REPOS_LIMIT, MAX_REPOS),
|
||||||
|
timeoutMs: clampInt(opts.timeoutMs, MIN_GIT_TIMEOUT_MS, MAX_GIT_TIMEOUT_MS, DEFAULT_GIT_TIMEOUT_MS),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceRepos =
|
type WorkspaceRepos =
|
||||||
| { kind: 'docker' }
|
| { kind: 'docker' }
|
||||||
| { kind: 'error'; error: string }
|
| { kind: 'error'; error: string }
|
||||||
| { kind: 'enclosing'; root: string }
|
| { kind: 'enclosing'; root: string }
|
||||||
| { kind: 'children'; dirs: string[]; truncated: boolean };
|
| { kind: 'children'; dirs: string[]; truncated: boolean; limit: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WHICH repositories belong to the workspace (the module header has the rules), without a full
|
* WHICH repositories belong to the workspace (the module header has the rules), without a full
|
||||||
@@ -617,6 +647,7 @@ type WorkspaceRepos =
|
|||||||
*/
|
*/
|
||||||
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
||||||
const now = opts.now ?? Date.now;
|
const now = opts.now ?? Date.now;
|
||||||
|
const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
|
||||||
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
||||||
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
|
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
|
||||||
// could carry config (a clean filter) that runs on the host.
|
// could carry config (a clean filter) that runs on the host.
|
||||||
@@ -624,7 +655,7 @@ async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Pro
|
|||||||
// The enclosing repository is identified before its full status runs, so an unrelated one above the
|
// The enclosing repository is identified before its full status runs, so an unrelated one above the
|
||||||
// workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
|
// workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
|
||||||
// repositories below.
|
// repositories below.
|
||||||
const top = await enclosingRepoRoot(cwd, opts);
|
const top = await enclosingRepoRoot(cwd, { ...opts, timeoutMs });
|
||||||
if (top.state === 'error') return { kind: 'error', error: top.error };
|
if (top.state === 'error') return { kind: 'error', error: top.error };
|
||||||
if (top.state === 'ok') {
|
if (top.state === 'ok') {
|
||||||
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
|
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
|
||||||
@@ -633,18 +664,20 @@ async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not inside a repository of this workspace: look below for projects.
|
// Not inside a repository of this workspace: look below for projects.
|
||||||
const hit = discoveryCache.get(cwd);
|
// Keyed by the limit too: a list cut at 12 must not answer a request for 30.
|
||||||
|
const discoveryKey = `${cwd}\0${maxRepos}`;
|
||||||
|
const hit = discoveryCache.get(discoveryKey);
|
||||||
let found: { dirs: string[]; truncated: boolean };
|
let found: { dirs: string[]; truncated: boolean };
|
||||||
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
|
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
|
||||||
else {
|
else {
|
||||||
found = await discoverChildRepos(cwd, dockerRoots);
|
found = await discoverChildRepos(cwd, dockerRoots, maxRepos);
|
||||||
discoveryCache.set(cwd, { at: now(), value: found });
|
discoveryCache.set(discoveryKey, { at: now(), value: found });
|
||||||
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
|
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
|
||||||
}
|
}
|
||||||
// The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
|
// The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
|
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
|
||||||
return { kind: 'children', dirs, truncated: found.truncated };
|
return { kind: 'children', dirs, truncated: found.truncated, limit: maxRepos };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -659,7 +692,7 @@ export async function getGitWorkspaceOverview(
|
|||||||
if (where.kind === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
|
if (where.kind === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
|
||||||
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
|
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
|
||||||
if (where.kind === 'enclosing') {
|
if (where.kind === 'enclosing') {
|
||||||
const primary = await getGitWorkspaceStatus(cwd, opts);
|
const primary = await getGitWorkspaceStatus(cwd, { ...opts, timeoutMs: resolveOverviewLimits(opts).timeoutMs });
|
||||||
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
|
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
|
||||||
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
|
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
|
||||||
const root = primary.repoRoot ?? where.root;
|
const root = primary.repoRoot ?? where.root;
|
||||||
@@ -671,14 +704,21 @@ export async function getGitWorkspaceOverview(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, opts));
|
const timeoutMs = resolveOverviewLimits(opts).timeoutMs;
|
||||||
|
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) =>
|
||||||
|
getGitWorkspaceStatus(dir, { ...opts, timeoutMs })
|
||||||
|
);
|
||||||
const repos: GitRepoEntry[] = [];
|
const repos: GitRepoEntry[] = [];
|
||||||
where.dirs.forEach((dir, i) => {
|
where.dirs.forEach((dir, i) => {
|
||||||
const status = statuses[i];
|
const status = statuses[i];
|
||||||
if (status.state === 'ok') repos.push({ name: basename(dir), path: relative(cwd, dir), status });
|
// A repository git could not read (a timeout on a slow share, a broken worktree) stays in the
|
||||||
|
// list with its error, so it is visible that something is not being reported; only a folder
|
||||||
|
// that turned out not to be a repository after all is left out.
|
||||||
|
if (status.state === 'ok' || status.state === 'error')
|
||||||
|
repos.push({ name: basename(dir), path: relative(cwd, dir), status });
|
||||||
});
|
});
|
||||||
if (!repos.length) return emptyOverview('not-a-repo');
|
if (!repos.length) return emptyOverview('not-a-repo');
|
||||||
return { state: 'ok', repos, reposTruncated: where.truncated, checkedAt: Date.now() };
|
return { state: 'ok', repos, reposTruncated: where.truncated, repoLimit: where.limit, checkedAt: Date.now() };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -726,7 +766,7 @@ export function isSafeRepoRelativePath(p: string): boolean {
|
|||||||
export async function getGitFileDiff(
|
export async function getGitFileDiff(
|
||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
file: { path: string; origPath?: string; kind: GitFileKind },
|
file: { path: string; origPath?: string; kind: GitFileKind },
|
||||||
opts: { git?: GitRunner } = {}
|
opts: { git?: GitRunner; timeoutMs?: number } = {}
|
||||||
): Promise<GitFileDiff> {
|
): Promise<GitFileDiff> {
|
||||||
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
|
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
|
||||||
throw new Error('Invalid path');
|
throw new Error('Invalid path');
|
||||||
@@ -742,7 +782,7 @@ export async function getGitFileDiff(
|
|||||||
let out: string;
|
let out: string;
|
||||||
let cutShort = false;
|
let cutShort = false;
|
||||||
try {
|
try {
|
||||||
out = await git(repoRoot, args);
|
out = await git(repoRoot, args, { timeoutMs: opts.timeoutMs });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as { code?: unknown; stdout?: unknown };
|
const e = err as { code?: unknown; stdout?: unknown };
|
||||||
// `--no-index` exits 1 when the files differ, which is the normal case for it.
|
// `--no-index` exits 1 when the files differ, which is the normal case for it.
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
|
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
|
||||||
this._gitStatusFetchedAt = Date.now();
|
this._gitStatusFetchedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status${fresh ? '?fresh=1' : ''}`);
|
const query = new URLSearchParams(this.gitStatusLimits());
|
||||||
|
if (fresh) query.set('fresh', '1');
|
||||||
|
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status?${query}`);
|
||||||
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
|
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
|
||||||
this._gitStatus = data ? { sessionId: sid, data } : null;
|
this._gitStatus = data ? { sessionId: sid, data } : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -131,6 +133,23 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many repositories to list below a folder that is not itself a repository, and how long one
|
||||||
|
* git command may run, in seconds (Settings → Bottom bar, per device). The server clamps both again.
|
||||||
|
*/
|
||||||
|
gitStatusLimits() {
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
const defaults = this.getDefaultSettings();
|
||||||
|
const num = (v, min, max, fallback) => {
|
||||||
|
const n = Math.trunc(Number(v));
|
||||||
|
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
maxRepos: num(settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos, 1, 50, 12),
|
||||||
|
timeout: num(settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds, 5, 120, 30),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
/** Whether the Git window groups changed files under collapsible folders (default on). */
|
/** Whether the Git window groups changed files under collapsible folders (default on). */
|
||||||
isGitStatusTree() {
|
isGitStatusTree() {
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
@@ -150,13 +169,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
let uncommitted = 0;
|
let uncommitted = 0;
|
||||||
let unpushed = 0;
|
let unpushed = 0;
|
||||||
let conflicted = 0;
|
let conflicted = 0;
|
||||||
|
let unreadable = 0;
|
||||||
for (const r of overview.repos) {
|
for (const r of overview.repos) {
|
||||||
|
if (r.status.state === 'error') unreadable += 1;
|
||||||
uncommitted += r.status.counts.uncommitted;
|
uncommitted += r.status.counts.uncommitted;
|
||||||
unpushed += r.status.unpushedCount;
|
unpushed += r.status.unpushedCount;
|
||||||
conflicted += r.status.counts.conflicted;
|
conflicted += r.status.counts.conflicted;
|
||||||
}
|
}
|
||||||
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 ? 'dirty' : 'clean';
|
// A repository that could not be read is not "clean": it must not let the indicator say ✓.
|
||||||
return { uncommitted, unpushed, conflicted, repos: overview.repos.length, tone };
|
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 || unreadable > 0 ? 'dirty' : 'clean';
|
||||||
|
return { uncommitted, unpushed, conflicted, unreadable, repos: overview.repos.length, tone };
|
||||||
},
|
},
|
||||||
|
|
||||||
/** One sentence for the tooltip and the screen-reader label. */
|
/** One sentence for the tooltip and the screen-reader label. */
|
||||||
@@ -168,6 +190,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
|
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.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 (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
|
||||||
|
if (sum.unreadable)
|
||||||
|
bits.push(plural(sum.unreadable, 'repository could not be read', 'repositories could not be read'));
|
||||||
if (!bits.length) bits.push('everything is committed and pushed');
|
if (!bits.length) bits.push('everything is committed and pushed');
|
||||||
let where;
|
let where;
|
||||||
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
||||||
@@ -196,6 +220,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
|
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
|
||||||
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
||||||
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
||||||
|
if (sum.unreadable) parts.push(`? ${sum.unreadable}`);
|
||||||
if (!parts.length) parts.push('✓');
|
if (!parts.length) parts.push('✓');
|
||||||
if (label) label.textContent = parts.join(' ');
|
if (label) label.textContent = parts.join(' ');
|
||||||
const sentence = this._gitStatusSentence(data);
|
const sentence = this._gitStatusSentence(data);
|
||||||
@@ -343,7 +368,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
for (const r of repos) body.append(this._gitRepoSection(r));
|
for (const r of repos) body.append(this._gitRepoSection(r));
|
||||||
if (overview.reposTruncated) {
|
if (overview.reposTruncated) {
|
||||||
body.append(
|
body.append(
|
||||||
el('div', 'git-status-more', `Showing the first ${repos.length} repositories found under this folder.`)
|
el(
|
||||||
|
'div',
|
||||||
|
'git-status-more',
|
||||||
|
`Showing the first ${overview.repoLimit || repos.length} of more than ${overview.repoLimit || repos.length} repositories under this folder. Raise “Git status: max repositories” in Settings → Bottom bar to see more.`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +393,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_gitRepoSection(r) {
|
_gitRepoSection(r) {
|
||||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||||
const d = r.status;
|
const d = r.status;
|
||||||
|
if (d.state === 'error') {
|
||||||
|
// Kept in the list with the reason, rather than silently left out.
|
||||||
|
const row = el('div', 'git-status-repo git-status-repo--error');
|
||||||
|
row.append(el('span', 'git-status-repo-name', r.name));
|
||||||
|
if (r.path !== r.name) row.append(el('span', 'git-status-repo-path', r.path));
|
||||||
|
const why = el('span', 'git-status-repo-unreadable', `⚠ could not read: ${d.error || 'git failed'}`);
|
||||||
|
why.title = 'If this is a timeout, raise “Git status: git timeout” in Settings → Bottom bar.';
|
||||||
|
row.append(why);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
const section = el('details', 'git-status-repo');
|
const section = el('details', 'git-status-repo');
|
||||||
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
||||||
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
||||||
@@ -582,7 +621,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
|
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
|
||||||
this._gitDiffView = view;
|
this._gitDiffView = view;
|
||||||
this._renderGitStatusPanel();
|
this._renderGitStatusPanel();
|
||||||
const qs = new URLSearchParams({ repo: repoRoot, path: file.path, kind: file.kind });
|
const qs = new URLSearchParams({
|
||||||
|
repo: repoRoot,
|
||||||
|
path: file.path,
|
||||||
|
kind: file.kind,
|
||||||
|
...this.gitStatusLimits(),
|
||||||
|
});
|
||||||
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
|
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
|
||||||
// Back, another file or another session while this was in flight: drop the answer.
|
// Back, another file or another session while this was in flight: drop the answer.
|
||||||
if (this._gitDiffView !== view) return;
|
if (this._gitDiffView !== view) return;
|
||||||
|
|||||||
@@ -2009,6 +2009,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row" data-search="git status maximum repositories folder many repos limit">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Git status: max repositories <span class="set-scope">device</span></span>
|
||||||
|
<span class="set-row-desc">When the session's folder holds several projects instead of being one, the Git window lists up to this many (1 to 50, default 12). Each one costs a few git commands per refresh.</span>
|
||||||
|
</div>
|
||||||
|
<input type="number" id="appSettingsGitStatusMaxRepos" class="set-num" value="12" min="1" max="50" step="1">
|
||||||
|
</div>
|
||||||
|
<div class="set-row" data-search="git status timeout seconds slow share network">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Git status: git timeout <span class="set-scope">device</span></span>
|
||||||
|
<span class="set-row-desc">Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.</span>
|
||||||
|
</div>
|
||||||
|
<input type="number" id="appSettingsGitStatusTimeout" class="set-num" value="30" min="5" max="120" step="5">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -455,6 +455,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||||
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false;
|
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false;
|
||||||
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
|
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
|
||||||
|
document.getElementById('appSettingsGitStatusMaxRepos').value = settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos ?? 12;
|
||||||
|
document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30;
|
||||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
// 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
|
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||||
@@ -2552,6 +2554,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||||
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
||||||
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
|
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
|
||||||
|
// Clamped here and again on the server; an empty or odd value falls back to the default.
|
||||||
|
gitStatusMaxRepos: Math.min(50, Math.max(1, parseInt(document.getElementById('appSettingsGitStatusMaxRepos').value, 10) || 12)),
|
||||||
|
gitStatusTimeoutSeconds: Math.min(120, Math.max(5, parseInt(document.getElementById('appSettingsGitStatusTimeout').value, 10) || 30)),
|
||||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||||
@@ -2823,6 +2828,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
||||||
showGitStatus: _sgs,
|
showGitStatus: _sgs,
|
||||||
gitStatusTree: _gst,
|
gitStatusTree: _gst,
|
||||||
|
gitStatusMaxRepos: _gsm,
|
||||||
|
gitStatusTimeoutSeconds: _gst2,
|
||||||
showTabDetachButton: _tdb,
|
showTabDetachButton: _tdb,
|
||||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||||
mobileOverviewEnabled: _mov,
|
mobileOverviewEnabled: _mov,
|
||||||
@@ -4228,7 +4235,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'language',
|
'language',
|
||||||
'terminalWheelLocalScrollback',
|
'terminalWheelLocalScrollback',
|
||||||
'autoCopySelection', 'copyStripMargin',
|
'autoCopySelection', 'copyStripMargin',
|
||||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
|
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
|
||||||
'showTabDetachButton',
|
'showTabDetachButton',
|
||||||
'mobileOverviewEnabled',
|
'mobileOverviewEnabled',
|
||||||
'sessionLineageLines',
|
'sessionLineageLines',
|
||||||
|
|||||||
@@ -20586,6 +20586,22 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
|
|||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Git status panel: a repository git could not read stays in the list, with the reason */
|
||||||
|
.git-status-repo--error {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-repo-unreadable {
|
||||||
|
color: #e0a030;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Tab grouping by state (tabArrangement: 'state', opt-in;
|
Tab grouping by state (tabArrangement: 'state', opt-in;
|
||||||
Discussion #426 option C)
|
Discussion #426 option C)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
emptyOverview,
|
emptyOverview,
|
||||||
findWorkspaceRepo,
|
findWorkspaceRepo,
|
||||||
getGitFileDiff,
|
getGitFileDiff,
|
||||||
|
resolveOverviewLimits,
|
||||||
getGitWorkspaceOverview,
|
getGitWorkspaceOverview,
|
||||||
getGitWorkspaceStatus,
|
getGitWorkspaceStatus,
|
||||||
type GitFileDiff,
|
type GitFileDiff,
|
||||||
@@ -33,6 +34,18 @@ import type { SessionPort } from '../ports/index.js';
|
|||||||
const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
||||||
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
|
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repository limit and git timeout a request asks for (Settings → Bottom bar, per device):
|
||||||
|
* `maxRepos` and `timeout` (seconds) query parameters, each clamped to a safe range, so an odd value
|
||||||
|
* can never cost more than the module's own ceiling.
|
||||||
|
*/
|
||||||
|
function limitsFrom(query: { maxRepos?: string; timeout?: string }) {
|
||||||
|
return resolveOverviewLimits({
|
||||||
|
maxRepos: query.maxRepos,
|
||||||
|
timeoutMs: query.timeout?.trim() ? Number(query.timeout) * 1000 : undefined, // '' and absent mean "default"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function registerGitStatusRoutes(
|
export function registerGitStatusRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
ctx: SessionPort,
|
ctx: SessionPort,
|
||||||
@@ -41,7 +54,9 @@ export function registerGitStatusRoutes(
|
|||||||
): void {
|
): void {
|
||||||
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
|
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { fresh } = req.query as { fresh?: string };
|
const query = req.query as { fresh?: string; maxRepos?: string; timeout?: string };
|
||||||
|
const { fresh } = query;
|
||||||
|
const limits = limitsFrom(query);
|
||||||
const session = findSessionOrFail(ctx, id, req);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
|
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
|
||||||
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
|
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
|
||||||
@@ -50,6 +65,7 @@ export function registerGitStatusRoutes(
|
|||||||
data: await getGitWorkspaceOverview(session.workingDir, {
|
data: await getGitWorkspaceOverview(session.workingDir, {
|
||||||
git,
|
git,
|
||||||
fresh: fresh === '1',
|
fresh: fresh === '1',
|
||||||
|
...limits,
|
||||||
dockerWorkspaces: await dockerWorkspaces(),
|
dockerWorkspaces: await dockerWorkspaces(),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -62,16 +78,24 @@ export function registerGitStatusRoutes(
|
|||||||
// repository's status is refreshed: a click must not re-read every repository in the folder.
|
// repository's status is refreshed: a click must not re-read every repository in the folder.
|
||||||
app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
|
app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { repo, path, kind } = req.query as { repo?: string; path?: string; kind?: string };
|
const query = req.query as { repo?: string; path?: string; kind?: string; maxRepos?: string; timeout?: string };
|
||||||
|
const { repo, path, kind } = query;
|
||||||
|
const limits = limitsFrom(query);
|
||||||
const session = findSessionOrFail(ctx, id, req);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
if (session.remote || session.docker) {
|
if (session.remote || session.docker) {
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
|
||||||
}
|
}
|
||||||
const repoRoot = repo
|
const repoRoot = repo
|
||||||
? await findWorkspaceRepo(session.workingDir, repo, { git, dockerWorkspaces: await dockerWorkspaces() })
|
? await findWorkspaceRepo(session.workingDir, repo, {
|
||||||
|
git,
|
||||||
|
...limits,
|
||||||
|
dockerWorkspaces: await dockerWorkspaces(),
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const status = repoRoot
|
||||||
|
? await getGitWorkspaceStatus(repoRoot, { git, fresh: true, timeoutMs: limits.timeoutMs })
|
||||||
: null;
|
: null;
|
||||||
const status = repoRoot ? await getGitWorkspaceStatus(repoRoot, { git, fresh: true }) : null;
|
|
||||||
const entry =
|
const entry =
|
||||||
status?.state === 'ok'
|
status?.state === 'ok'
|
||||||
? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
|
? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
|
||||||
@@ -81,7 +105,10 @@ export function registerGitStatusRoutes(
|
|||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
|
return {
|
||||||
|
success: true,
|
||||||
|
data: await getGitFileDiff(status.repoRoot, entry, { git, timeoutMs: limits.timeoutMs }),
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reply.code(500);
|
reply.code(500);
|
||||||
return createErrorResponse(
|
return createErrorResponse(
|
||||||
|
|||||||
@@ -398,6 +398,82 @@ describe('Git status indicator in a real browser', () => {
|
|||||||
await page.waitForSelector('#gitStatusPanel.visible');
|
await page.waitForSelector('#gitStatusPanel.visible');
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
it('sends the max-repositories and git-timeout settings, and lists an unreadable repository with its reason', async () => {
|
||||||
|
const setLimits = async (maxRepos: string, timeout: string) => {
|
||||||
|
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||||
|
await page.fill('#appSettingsGitStatusMaxRepos', maxRepos);
|
||||||
|
await page.fill('#appSettingsGitStatusTimeout', timeout);
|
||||||
|
await page.evaluate(() => (window as any).app.saveAppSettings());
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.evaluate(() => (window as any).app.closeAppSettings());
|
||||||
|
};
|
||||||
|
await setLimits('7', '45');
|
||||||
|
// Per-device keys must never reach the strict PUT /api/settings.
|
||||||
|
expect(settingsPutStatuses.every((st) => st === 200)).toBe(true);
|
||||||
|
expect(await page.evaluate(() => (window as any).app.gitStatusLimits())).toEqual({ maxRepos: 7, timeout: 45 });
|
||||||
|
await refresh();
|
||||||
|
expect(gitStatusRequests.at(-1)).toMatch(/maxRepos=7/);
|
||||||
|
expect(gitStatusRequests.at(-1)).toMatch(/timeout=45/);
|
||||||
|
|
||||||
|
// Out-of-range values are clamped when saved, not sent as typed.
|
||||||
|
await setLimits('9999', '1');
|
||||||
|
expect(await page.evaluate(() => (window as any).app.gitStatusLimits())).toEqual({ maxRepos: 50, timeout: 5 });
|
||||||
|
|
||||||
|
// A folder with more repositories than the limit, one of which git could not read.
|
||||||
|
const emptyCounts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 };
|
||||||
|
const status = (over: Record<string, unknown>) => ({
|
||||||
|
state: 'ok',
|
||||||
|
branch: 'main',
|
||||||
|
detached: false,
|
||||||
|
upstream: 'origin/main',
|
||||||
|
upstreamGone: false,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
hasRemote: true,
|
||||||
|
counts: emptyCounts,
|
||||||
|
files: [],
|
||||||
|
filesTruncated: false,
|
||||||
|
unpushedCount: 0,
|
||||||
|
unpushed: [],
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
await page.route('**/api/sessions/*/git-status*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
state: 'ok',
|
||||||
|
reposTruncated: true,
|
||||||
|
repoLimit: 2,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
repos: [
|
||||||
|
{ name: 'fast', path: 'fast', status: status({ repoRoot: '/x/fast' }) },
|
||||||
|
{ name: 'slow', path: 'slow', status: status({ state: 'error', error: 'git timed out' }) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await refresh();
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
/could not read/.test(document.getElementById('gitStatusBody')?.textContent ?? '')
|
||||||
|
);
|
||||||
|
const body = (await page.textContent('#gitStatusBody')) ?? '';
|
||||||
|
expect(body).toContain('slow');
|
||||||
|
expect(body).toContain('could not read: git timed out');
|
||||||
|
expect(body).toContain('Showing the first 2 of more than 2 repositories');
|
||||||
|
expect(body).toContain('Raise “Git status: max repositories”');
|
||||||
|
// The unreadable repository must keep the indicator from claiming everything is fine.
|
||||||
|
expect(await label()).toContain('? 1');
|
||||||
|
expect(await label()).not.toContain('✓');
|
||||||
|
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/1 repository could not be read/);
|
||||||
|
await page.unroute('**/api/sessions/*/git-status*');
|
||||||
|
await setLimits('12', '30');
|
||||||
|
await refresh();
|
||||||
|
}, 40000);
|
||||||
|
|
||||||
it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => {
|
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"]');
|
await page.click('.git-status-actions button[aria-label="Close git status"]');
|
||||||
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
|
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
|
||||||
|
|||||||
@@ -896,3 +896,99 @@ describe('Docker case workspaces are never inspected', () => {
|
|||||||
expect(o.state).toBe('ok');
|
expect(o.state).toBe('ok');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
clampInt,
|
||||||
|
DEFAULT_GIT_TIMEOUT_MS,
|
||||||
|
MAX_GIT_TIMEOUT_MS,
|
||||||
|
MAX_REPOS_LIMIT,
|
||||||
|
MIN_GIT_TIMEOUT_MS,
|
||||||
|
resolveOverviewLimits,
|
||||||
|
} from '../src/git-workspace-status.js';
|
||||||
|
|
||||||
|
describe('configurable repository limit and git timeout', () => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
top = mkdtempSync(join(tmpdir(), 'git-limits-'));
|
||||||
|
home = join(top, 'home');
|
||||||
|
mkdir(home, { recursive: true });
|
||||||
|
clearGitStatusCache();
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(top, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it('clampInt keeps untrusted values inside the range, and falls back for anything that is not a number', () => {
|
||||||
|
expect(clampInt('7', 1, 50, 12)).toBe(7);
|
||||||
|
expect(clampInt(7.9, 1, 50, 12)).toBe(7);
|
||||||
|
expect(clampInt(0, 1, 50, 12)).toBe(1);
|
||||||
|
expect(clampInt(9999, 1, 50, 12)).toBe(50);
|
||||||
|
for (const bad of [undefined, null, '', 'abc', NaN, Infinity, {}]) expect(clampInt(bad, 1, 50, 12)).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveOverviewLimits defaults to 12 repositories and a 30 s timeout, and clamps both ends', () => {
|
||||||
|
expect(resolveOverviewLimits({})).toEqual({ maxRepos: MAX_REPOS, timeoutMs: DEFAULT_GIT_TIMEOUT_MS });
|
||||||
|
expect(DEFAULT_GIT_TIMEOUT_MS).toBe(30_000);
|
||||||
|
expect(resolveOverviewLimits({ maxRepos: 500, timeoutMs: 10 ** 9 })).toEqual({
|
||||||
|
maxRepos: MAX_REPOS_LIMIT,
|
||||||
|
timeoutMs: MAX_GIT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
expect(resolveOverviewLimits({ maxRepos: -3, timeoutMs: 1 })).toEqual({
|
||||||
|
maxRepos: 1,
|
||||||
|
timeoutMs: MIN_GIT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists up to maxRepos, says what the limit was, and a different limit is not answered from the old cache', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
for (const n of ['a', 'b', 'c', 'd', 'e']) repoAt(join(ws, n));
|
||||||
|
const three = await getGitWorkspaceOverview(ws, { home, maxRepos: 3 });
|
||||||
|
expect(three.repos.map((r) => r.name)).toEqual(['a', 'b', 'c']);
|
||||||
|
expect(three).toMatchObject({ reposTruncated: true, repoLimit: 3 });
|
||||||
|
// No `fresh`: the 30 s discovery cache must be keyed by the limit.
|
||||||
|
const ten = await getGitWorkspaceOverview(ws, { home, maxRepos: 10 });
|
||||||
|
expect(ten.repos).toHaveLength(5);
|
||||||
|
expect(ten).toMatchObject({ reposTruncated: false, repoLimit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a repository git could not read in the list, with the reason, instead of dropping it', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
for (const n of ['fast', 'slow']) repoAt(join(ws, n));
|
||||||
|
const flaky: GitRunner = (cwd, args, opts) => {
|
||||||
|
if (cwd.endsWith('slow')) return Promise.reject(Object.assign(new Error('timed out'), { killed: true }));
|
||||||
|
return runGit(cwd, args, opts);
|
||||||
|
};
|
||||||
|
const o = await getGitWorkspaceOverview(ws, { home, git: flaky });
|
||||||
|
expect(o.repos.map((r) => [r.name, r.status.state])).toEqual([
|
||||||
|
['fast', 'ok'],
|
||||||
|
['slow', 'error'],
|
||||||
|
]);
|
||||||
|
expect(o.repos[1].status.error).toBe('git timed out');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the timeout to every git command, clamped', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
repoAt(join(ws, 'a'));
|
||||||
|
const seen: Array<number | undefined> = [];
|
||||||
|
const spy: GitRunner = (cwd, args, opts) => {
|
||||||
|
seen.push(opts?.timeoutMs);
|
||||||
|
return runGit(cwd, args, opts);
|
||||||
|
};
|
||||||
|
await getGitWorkspaceOverview(ws, { home, git: spy, timeoutMs: 45_000, fresh: true });
|
||||||
|
expect(seen.length).toBeGreaterThan(0);
|
||||||
|
expect(new Set(seen)).toEqual(new Set([45_000]));
|
||||||
|
|
||||||
|
clearGitStatusCache();
|
||||||
|
seen.length = 0;
|
||||||
|
await getGitWorkspaceOverview(ws, { home, git: spy, timeoutMs: 10 ** 9, fresh: true });
|
||||||
|
expect(new Set(seen)).toEqual(new Set([MAX_GIT_TIMEOUT_MS]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -298,3 +298,50 @@ describe('GET /api/sessions/:id/git-diff in a folder of several repositories', (
|
|||||||
expect(calls.filter(([, verb]) => verb === 'status').map(([cwd]) => cwd)).toEqual([api]);
|
expect(calls.filter(([, verb]) => verb === 'status').map(([cwd]) => cwd)).toEqual([api]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id/git-status limits', () => {
|
||||||
|
it('honours maxRepos and timeout (seconds), clamped, and reports the limit it used', async () => {
|
||||||
|
for (const n of ['a', 'b', 'c']) {
|
||||||
|
mkdirSync(join(dir, n));
|
||||||
|
git(join(dir, n), 'init', '-q', '-b', 'main');
|
||||||
|
}
|
||||||
|
const seen: Array<number | undefined> = [];
|
||||||
|
const spy: GitRunner = (cwd, args, opts) => {
|
||||||
|
seen.push(opts?.timeoutMs);
|
||||||
|
return execFileSyncGit(cwd, args);
|
||||||
|
};
|
||||||
|
const { app } = await setup({ git: spy });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/sessions/test-session-1/git-status?maxRepos=2&timeout=7',
|
||||||
|
});
|
||||||
|
const data = res.json().data;
|
||||||
|
expect(data.repos.map((r: { name: string }) => r.name)).toEqual(['a', 'b']);
|
||||||
|
expect(data).toMatchObject({ reposTruncated: true, repoLimit: 2 });
|
||||||
|
expect(new Set(seen)).toEqual(new Set([7000]));
|
||||||
|
|
||||||
|
clearGitStatusCache();
|
||||||
|
const wild = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/sessions/test-session-1/git-status?maxRepos=9999&timeout=1&fresh=1',
|
||||||
|
});
|
||||||
|
expect(wild.json().data.repoLimit).toBe(50);
|
||||||
|
expect(seen.at(-1)).toBe(5000);
|
||||||
|
|
||||||
|
clearGitStatusCache();
|
||||||
|
const junk = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/sessions/test-session-1/git-status?maxRepos=abc&timeout=xyz&fresh=1',
|
||||||
|
});
|
||||||
|
expect(junk.json().data.repoLimit).toBe(12);
|
||||||
|
expect(seen.at(-1)).toBe(30_000);
|
||||||
|
|
||||||
|
clearGitStatusCache();
|
||||||
|
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?maxRepos=&timeout=&fresh=1' });
|
||||||
|
expect(seen.at(-1)).toBe(30_000); // empty means "not given", not 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function execFileSyncGit(cwd: string, args: string[]): Promise<string> {
|
||||||
|
return Promise.resolve(execFileSync('git', ['--no-optional-locks', ...args], { cwd, env: ENV, encoding: 'utf8' }));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user