Files
AgentHub/server/lib/nested-repos.js
T
nntrivi2001 c8bc4a95a9 chore: v2.8.23 — workspace groups, inner repos in Changes (opt-in), sub-agent tokens counted
Workspace groups: named, foldable, ordered sidebar sections kept per
account (agenthub.workspace.groups); a workspace joins one from its menu,
a drop on the header, a drag under a row, or Alt+Up/Down past a group's
edge; headers drag to reorder; a folded header shows its most urgent
state; New workspace in this group; fold/unfold all.

Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the
Changes panel shows a parent folder's repos only when Settings -> Git ->
Changes panel turns it on (agenthub.changes.innerRepos, off by default).

Cost: the hook writer adds a session's sub-agent transcripts to its
tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the
session's own model was never counted live (~8% of a week here); an
imported session is re-raised on every pass, so a claude -p run first
seen with an empty transcript no longer stays at $0 (~10%). A one-time
raise-only catch-up (user_version 5) re-reads ~/.claude/projects.

Also: omp install script may redirect to its exact install.redirect URL
(#85, repo-links test exempts it), native paste in the terminal.
2026-10-23 16:41:45 +07:00

148 lines
4.8 KiB
JavaScript

/**
* @file nested-repos.js
* @description The git repositories INSIDE a workspace folder — a parent
* folder holding several repos side by side (`~/work/shop/{api,web,admin}`),
* each with its own `.git` — so the Changes panel can show one section per
* repo, like VS Code's Source Control. Found by reading the disk only (no git
* call): a directory holding a `.git` entry (a directory, or the file a
* submodule or linked worktree has) is a repo, and the walk does not go
* inside one. At most `MAX_DEPTH` levels below the workspace folder, never
* through a symlink, never into a dot directory or `node_modules`/`vendor`,
* at most `MAX_REPOS` repos and `MAX_ENTRIES` directory entries read, so a
* folder with a huge tree (or a member flooding theirs) costs a bounded read.
* Cached per folder for `TTL_MS`; `refresh` reads again.
*
* `repoFolder` is the git routes' `?repo=<path>`: the request names a path
* RELATIVE to the workspace folder, and it is accepted only when it is one of
* the repos this walk found — never a path the walk did not produce, so the
* request can no more point the git routes at an arbitrary directory than
* `?source=` can.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const MAX_DEPTH = 2;
const MAX_REPOS = 50;
const MAX_ENTRIES = 5000;
const TTL_MS = 30_000;
const REREAD_MS = 2_000;
const SKIP = new Set(["node_modules", "vendor", "dist", "build", "target", "__pycache__"]);
const cache = new Map();
function fail(code, message) {
return Object.assign(new Error(message), { code });
}
async function hasGit(dir) {
try {
await fs.promises.lstat(path.join(dir, ".git"));
return true;
} catch {
return false;
}
}
/** Whether `dir` or a folder above it holds a `.git` — the folder is itself in a repo. */
async function insideRepo(dir) {
for (let d = dir; ;) {
if (await hasGit(d)) return true;
const up = path.dirname(d);
if (up === d) return false;
d = up;
}
}
async function walk(root) {
const repos = [];
let budget = MAX_ENTRIES;
let truncated = false;
const visit = async (dir, depth) => {
let handle;
try {
handle = await fs.promises.opendir(dir);
} catch {
return; // unreadable: not ours to report
}
const children = [];
try {
for await (const ent of handle) {
if (--budget < 0) {
truncated = true;
break;
}
// `isDirectory()` is false for a symlink: a link is never followed.
if (!ent.isDirectory() || ent.name.startsWith(".") || SKIP.has(ent.name)) continue;
children.push(ent.name);
}
} catch {
/* a directory that vanished mid-read */
}
children.sort((a, b) => a.localeCompare(b));
for (const name of children) {
if (repos.length >= MAX_REPOS) {
truncated = true;
return;
}
const abs = path.join(dir, name);
if (await hasGit(abs)) {
const rel = path.relative(root, abs).split(path.sep).join("/");
repos.push({ path: rel, name: rel });
} else if (depth < MAX_DEPTH) {
await visit(abs, depth + 1);
}
}
};
await visit(root, 1);
return { items: repos, truncated };
}
/**
* The repos below `cwd`, plus whether `cwd` itself is in a repo (`root`).
* @param {string} cwd The workspace folder (absolute).
* @param {{refresh?: boolean}} [opts]
* @returns {Promise<{root: boolean, items: {path: string, name: string}[], truncated: boolean}>}
*/
async function nestedRepos(cwd, { refresh = false } = {}) {
const hit = cache.get(cwd);
if (!refresh && hit && Date.now() - hit.at < TTL_MS) return hit.value;
const pending = (async () => {
const [root, found] = await Promise.all([insideRepo(cwd), walk(cwd)]);
return { root, ...found };
})();
cache.set(cwd, { at: Date.now(), value: pending });
try {
return await pending;
} catch (err) {
cache.delete(cwd);
throw err;
}
}
/**
* The directory `?repo=<rel>` names: only a repo `nestedRepos` found under
* the workspace folder (re-read if the cached list does not have it — a repo
* cloned in the last 30 s — but at most once per `REREAD_MS`, so a stream of
* bad names cannot keep the disk walking).
* @throws ENOREPO otherwise.
*/
async function repoFolder(cwd, rel) {
const want = String(rel);
const find = (list) => list.items.find((r) => r.path === want);
let hit = find(await nestedRepos(cwd));
if (!hit && Date.now() - (cache.get(cwd)?.at ?? 0) >= REREAD_MS) {
hit = find(await nestedRepos(cwd, { refresh: true }));
}
if (!hit) throw fail("ENOREPO", "that folder is not a repository inside this workspace");
return path.join(cwd, ...hit.path.split("/"));
}
/** Tests only. */
function clearNestedRepoCache() {
cache.clear();
}
module.exports = { nestedRepos, repoFolder, clearNestedRepoCache, MAX_DEPTH, MAX_REPOS };