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.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @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 };
|
||||
Reference in New Issue
Block a user