c8bc4a95a9
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.
148 lines
4.8 KiB
JavaScript
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 };
|