fix: v2.3.1 — correct version, say why a plugin update failed, gitignored files in Files

- v2.3.0's tree reported 2.2.1, so every install saw an update forever;
  package.json, the lockfile, openapi.yaml, the CLAUDE.md marker and the
  snapshot now read 2.3.1
- A failed plugin-update step records the tail of the command's stdout and
  stderr plus its exit code or signal (failureText); plugin-bootstrap.js
  prints its refusal on stdout, which the verdict used to drop. Update all
  uses the same text
- updates.test.js never applies an update: it skips when the checkout is
  behind a release (apply ran npm run setup and pruned dev dependencies)
- Files tree lists gitignored files and folders, named in orange
  (addIgnored in git-diff.js); change-tree rows indent in whole steps
This commit is contained in:
2026-10-06 16:22:53 +07:00
parent 7ca1134c3d
commit 837da6e5de
14 changed files with 191 additions and 49 deletions
+1 -1
View File
@@ -530,7 +530,7 @@ Request body shape:
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the workspace), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the workspace. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. What git ignores at that level (read off the disk, `.git` excepted — also inside an ignored folder) is listed too with `ignored: true`; `tree-all` adds them per level but gives an ignored folder no level of its own. `400 EBADPATH` for a path escaping the workspace. |
| `GET` | `/api/lanes/:id/git/branches` | The workspace repo's own local branches + worktrees, for the branch switcher. The repo comes from the workspace row, never the query. |
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
+14 -2
View File
@@ -463,20 +463,32 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
it("lists untracked-but-not-ignored files and marks what changed, folders included", async () => {
write(LANE, "src/main.ts", "m\n");
write(LANE, ".gitignore", "*.log\n");
write(LANE, ".gitignore", "*.log\nvendor/\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "src");
write(LANE, "a.txt", "modified\n");
write(LANE, "new.txt", "fresh\n");
write(LANE, "gen/deep/out.txt", "wholly untracked folder\n");
write(LANE, "noise.log", "ignored\n");
write(LANE, "vendor/lib/x.js", "ignored folder\n");
const root = await listTree(LANE, {});
const by = Object.fromEntries(root.entries.map((e) => [e.name, e]));
assert.equal(by["a.txt"].status, " M");
assert.equal(by["new.txt"].status, "??");
assert.equal(by["keep.txt"].status, undefined);
assert.equal(by["noise.log"], undefined, "an ignored file stays out of the tree");
assert.equal(by["noise.log"].ignored, true, "an ignored file is listed, marked");
assert.equal(by.vendor.ignored, true);
assert.equal(by.vendor.dir, true);
assert.equal(by[".git"], undefined, "git's own dir is never listed");
assert.equal(by["a.txt"].ignored, undefined);
assert.equal(by.src.ignored, undefined);
// Inside an ignored folder, which `ls-files --ignored` cannot list.
const lib = await listTree(LANE, { subdir: "vendor/lib" });
assert.deepEqual(
lib.entries.map((e) => [e.name, e.ignored]),
[["x.js", true]]
);
assert.equal(by.gen.changed, true);
assert.equal(by.src.changed, undefined);
+33
View File
@@ -169,3 +169,36 @@ describe("plugin-update-helper name guard", () => {
assert.ok(!Number.isNaN(Date.parse(v.finished_at)));
});
});
describe("plugin-update-helper failureText", () => {
// Required, not spawned: the helper only runs main() as a script.
const { failureText } = require("../../scripts/plugin-update-helper");
it("names why the bootstrap refused, which it prints on stdout", () => {
const err = Object.assign(new Error("Command failed: node plugin-bootstrap.js --force"), {
status: 1,
stdout: "installing…\nbootstrap: locked\n",
stderr: "",
});
assert.equal(failureText(err), "installing…\nbootstrap: locked\n(exit 1)");
});
it("keeps only the last lines, stdout then stderr", () => {
const err = Object.assign(new Error("x"), {
status: 2,
stdout: ["1", "2", "3", "4", "5", "6", "7"].join("\n"),
stderr: "boom",
});
assert.equal(failureText(err), "3\n4\n5\n6\n7\nboom\n(exit 2)");
});
it("says a killed command was killed, and falls back to the message when nothing was printed", () => {
const err = Object.assign(new Error("Command failed: node b.js --force\n"), {
status: null,
signal: "SIGTERM",
stdout: "",
stderr: "",
});
assert.equal(failureText(err), "Command failed: node b.js --force (killed by SIGTERM)");
});
});
+11 -1
View File
@@ -91,7 +91,17 @@ describe("POST /api/updates/check", () => {
});
describe("POST /api/updates/apply", () => {
it("declines with a reason instead of pulling/restarting when there is nothing to apply", async () => {
it("declines with a reason instead of pulling/restarting when there is nothing to apply", async (t) => {
// Never let this test APPLY anything. When this checkout really is behind
// a release (or reports a version older than the newest tag), the route
// fast-forwards the repo, runs `npm run setup` — which under the shell's
// NODE_ENV=production prunes every dev dependency — and SIGTERMs the
// process to restart it. That happened once; ask first and stand down.
const status = await httpFetch("/api/updates/status");
if (status.body.update_available) {
t.skip(`this checkout is behind ${status.body.latest_release}; not applying it in a test`);
return;
}
// The test repo checkout has no update pending (or isn't fast-forwardable
// from a bare test run), so this exercises the decline path only — it must
// never touch the working tree or process in a test run.
+47 -5
View File
@@ -857,11 +857,45 @@ async function treeStatus(dir) {
};
}
/**
* Adds to one level what git does not list there — ignored files and folders
* (and an empty folder, which git never lists either) — marked `ignored`,
* read off the disk because `ls-files --ignored` cannot list INSIDE an
* ignored folder. Returns the folder names it added.
*/
async function addIgnored(dir, sub, dirNames, files) {
const added = new Set();
let dirents;
try {
const root = await fs.promises.realpath(dir);
const real = await fs.promises.realpath(path.join(root, sub));
const r = path.relative(root, real);
// A symlinked folder pointing out of the workspace lists nothing.
if (r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r)) return added;
dirents = await fs.promises.readdir(real, { withFileTypes: true });
} catch {
return added;
}
const prefix = sub ? `${sub}/` : "";
const listed = new Set(files.map((f) => f.name));
for (const d of dirents) {
if (d.name === ".git" || dirNames.has(d.name) || listed.has(d.name)) continue;
if (d.isDirectory()) {
dirNames.add(d.name);
added.add(d.name);
} else {
files.push({ name: d.name, path: `${prefix}${d.name}`, dir: false, ignored: true });
}
}
return added;
}
/** A folded level's entries, each carrying its status (see `treeStatus`). */
function treeEntries(prefix, dirNames, files, st) {
function treeEntries(prefix, dirNames, files, st, ignoredDirs = new Set()) {
return [
...[...dirNames].sort().map((name) => {
const p = `${prefix}${name}`;
if (ignoredDirs.has(name)) return { name, path: p, dir: true, ignored: true };
return { name, path: p, dir: true, ...(st.dirChanged(p) ? { changed: true } : {}) };
}),
...files
@@ -875,7 +909,8 @@ function treeEntries(prefix, dirNames, files, st) {
/**
* One directory level of the working tree, for the file explorer: tracked
* files plus untracked-but-not-ignored ones (`TREE_LS`), folded into
* files plus untracked-but-not-ignored ones (`TREE_LS`) and, marked
* `ignored`, what git ignores there (`addIgnored`), folded into
* directories at this level — a tree that lists a repo's every file at once is
* both slow to render and not what "expand a folder" means. A changed file
* carries its porcelain `status`, a folder holding one `changed: true`.
@@ -898,7 +933,8 @@ async function listTree(dir, { subdir = "" } = {}) {
if (slash === -1) files.push({ name: rest, path: p, dir: false });
else dirs.add(rest.slice(0, slash));
}
const entries = treeEntries(prefix, dirs, files, st);
const ignored = await addIgnored(dir, clean, dirs, files);
const entries = treeEntries(prefix, dirs, files, st, ignored);
return {
path: clean,
entries: entries.slice(0, MAX_PATHS),
@@ -995,7 +1031,7 @@ function onlyBlock(hunk, block, reverse) {
}
/**
* Every level of the explorer tree (tracked + untracked-not-ignored) from ONE `git ls-files`, keyed by directory
* Every level of the explorer tree (tracked + untracked-not-ignored, plus each level's ignored entries) from ONE `git ls-files`, keyed by directory
* path ("" is the root), each level shaped and capped exactly like
* `listTree`'s. "Expand all folders" used to be one request — one ls-files —
* per folder, which on a large repo is thousands of git processes.
@@ -1019,8 +1055,14 @@ async function listTreeAll(dir) {
}
const levels = {};
let truncated = cut;
// An ignored folder gets no level of its own: it stays shut (node_modules).
const ignored = new Map(
await Promise.all(
[...byDir].map(async ([d, l]) => [d, await addIgnored(dir, d, l.dirs, l.files)])
)
);
for (const [d, { dirs, files }] of byDir) {
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st);
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st, ignored.get(d));
if (entries.length > MAX_PATHS) truncated = true;
levels[d] = entries.slice(0, MAX_PATHS);
}