feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @file The Changes panel's JetBrains-style commit against a REAL repo:
|
||||
* `commit`/`amend` with `paths` take exactly the ticked files' working-tree
|
||||
* content (an untracked one included) and leave every other change — staged
|
||||
* or not — where it was; a failed commit puts a ticked untracked file back
|
||||
* out of the index. And `ignore` (Add to .gitignore): an anchored, escaped
|
||||
* line in the workspace's own .gitignore, only for an untracked path, never
|
||||
* twice, never through a symlink. Then the hunk-level commit (`hunks`: only
|
||||
* the ticked change blocks, through a temporary index, the real index synced
|
||||
* afterwards and the working tree untouched), `blameFile` (Annotate) and a
|
||||
* file's history with its parents rewritten so the graph joins up.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { blameFile, listCommits } = require("../lib/git-diff");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commit-paths-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
|
||||
const g = (...args) => {
|
||||
const env = { ...process.env };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
|
||||
};
|
||||
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
|
||||
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
|
||||
const status = () => g("status", "--porcelain=v1", "--untracked-files=all");
|
||||
const inHead = () => g("ls-tree", "-r", "--name-only", "HEAD").split("\n").filter(Boolean);
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
g("init", "-q", "-b", "main");
|
||||
g("config", "user.email", "t@h");
|
||||
g("config", "user.name", "t");
|
||||
write("a.txt", "a\n");
|
||||
write("b.txt", "b\n");
|
||||
write("c.txt", "c\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "init");
|
||||
});
|
||||
|
||||
describe("commit with paths", () => {
|
||||
it("commits only the ticked files, untracked included, and leaves the rest", async () => {
|
||||
write("a.txt", "a2\n"); // ticked, unstaged
|
||||
write("b.txt", "b2\n");
|
||||
g("add", "b.txt"); // staged but NOT ticked: must stay staged, uncommitted
|
||||
write("c.txt", "c2\n"); // not ticked
|
||||
write("new.txt", "n\n"); // ticked, untracked
|
||||
await runGitAction(REPO, "commit", { message: "pick", paths: ["a.txt", "new.txt"] });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "pick");
|
||||
assert.deepEqual(
|
||||
g("show", "--name-only", "--format=", "HEAD").split("\n").filter(Boolean).sort(),
|
||||
["a.txt", "new.txt"]
|
||||
);
|
||||
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
|
||||
assert.match(status(), /^M {2}b\.txt$/m);
|
||||
assert.match(status(), /^ M c\.txt$/m);
|
||||
});
|
||||
|
||||
it("commits a partly staged file's working-tree content, a deletion too", async () => {
|
||||
write("a.txt", "staged\n");
|
||||
g("add", "a.txt");
|
||||
write("a.txt", "staged\nand more\n");
|
||||
fs.rmSync(path.join(REPO, "b.txt"));
|
||||
await runGitAction(REPO, "commit", { message: "m", paths: ["a.txt", "b.txt"] });
|
||||
assert.equal(g("show", "HEAD:a.txt"), "staged\nand more\n");
|
||||
assert.ok(!inHead().includes("b.txt"));
|
||||
assert.equal(status(), "");
|
||||
});
|
||||
|
||||
it("refuses an empty list and a path git does not report changed", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: [] }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["b.txt"] }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
});
|
||||
|
||||
it("puts a ticked untracked file back out of the index when the commit fails", async () => {
|
||||
write("new.txt", "n\n");
|
||||
const hook = path.join(REPO, ".git", "hooks", "pre-commit");
|
||||
fs.writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["new.txt"] }));
|
||||
assert.match(status(), /^\?\? new\.txt$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("amend with paths", () => {
|
||||
it("an empty list rewords only, leaving the staged change out", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
g("add", "a.txt");
|
||||
const head = g("rev-parse", "HEAD").trim();
|
||||
await runGitAction(REPO, "amend", { message: "reworded", sha: head, paths: [] });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "reworded");
|
||||
assert.equal(g("show", "HEAD:a.txt"), "a\n");
|
||||
assert.match(status(), /^M {2}a\.txt$/m);
|
||||
});
|
||||
|
||||
it("folds the ticked files in, and refuses an empty list with no message", async () => {
|
||||
write("new.txt", "n\n");
|
||||
const head = g("rev-parse", "HEAD").trim();
|
||||
await assert.rejects(runGitAction(REPO, "amend", { sha: head, paths: [] }), {
|
||||
code: "ENOCHANGES",
|
||||
});
|
||||
await runGitAction(REPO, "amend", { sha: head, paths: ["new.txt"] });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "init");
|
||||
assert.ok(inHead().includes("new.txt"));
|
||||
assert.equal(g("rev-list", "--count", "HEAD").trim(), "1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ignore", () => {
|
||||
it("appends an anchored, escaped line once, keeping the file's last line", async () => {
|
||||
write(".gitignore", "node_modules");
|
||||
write("debug*.log", "x\n");
|
||||
write("note.md", "x\n");
|
||||
await runGitAction(REPO, "ignore", { paths: ["debug*.log", "note.md"] });
|
||||
assert.equal(read(".gitignore"), "node_modules\n/debug\\*.log\n/note.md\n");
|
||||
assert.doesNotMatch(status(), /debug|note/);
|
||||
});
|
||||
|
||||
it("creates the file when there is none, in a subdirectory too", async () => {
|
||||
fs.mkdirSync(path.join(REPO, "dir"));
|
||||
write("dir/x y.tmp", "x\n");
|
||||
await runGitAction(REPO, "ignore", { path: "dir/x y.tmp" });
|
||||
assert.equal(read(".gitignore"), "/dir/x y.tmp\n");
|
||||
assert.doesNotMatch(status(), /x y\.tmp/);
|
||||
});
|
||||
|
||||
it("refuses a tracked file and a .gitignore that is a symlink", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["a.txt"] }), { code: "ETRACKED" });
|
||||
write("new.txt", "n\n");
|
||||
const outside = path.join(ROOT, "outside-ignore");
|
||||
fs.writeFileSync(outside, "");
|
||||
fs.symlinkSync(outside, path.join(REPO, ".gitignore"));
|
||||
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["new.txt"] }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
assert.equal(fs.readFileSync(outside, "utf8"), "");
|
||||
});
|
||||
});
|
||||
|
||||
/** The `@@` lines of `file`'s HEAD → working tree diff at -U3, in order. */
|
||||
const headers = (file) =>
|
||||
g("diff", "-U3", "HEAD", "--", file)
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("@@ "));
|
||||
|
||||
describe("commit with hunks", () => {
|
||||
beforeEach(() => {
|
||||
write("long.txt", `${Array.from({ length: 30 }, (_, i) => i + 1).join("\n")}\n`);
|
||||
g("add", "long.txt");
|
||||
g("commit", "-qm", "long");
|
||||
});
|
||||
const edit = (pairs) => {
|
||||
let text = read("long.txt");
|
||||
for (const [from, to] of pairs) text = text.replace(new RegExp(`^${from}$`, "m"), to);
|
||||
write("long.txt", text);
|
||||
};
|
||||
|
||||
it("commits only the ticked hunk, leaving the rest in the working tree, unstaged", async () => {
|
||||
edit([
|
||||
["3", "three"],
|
||||
["25", "twentyfive"],
|
||||
]);
|
||||
const [first] = headers("long.txt");
|
||||
await runGitAction(REPO, "commit", {
|
||||
message: "part",
|
||||
context: 3,
|
||||
hunks: { "long.txt": [{ hunk: first, blocks: [0] }] },
|
||||
});
|
||||
const committed = g("show", "HEAD:long.txt");
|
||||
assert.match(committed, /^three$/m);
|
||||
assert.match(committed, /^25$/m);
|
||||
assert.match(read("long.txt"), /^twentyfive$/m);
|
||||
// The index followed the commit: the rest is an unstaged change only.
|
||||
assert.match(status(), /^ M long\.txt$/m);
|
||||
});
|
||||
|
||||
it("takes one block of a hunk that holds two, with whole files beside it", async () => {
|
||||
edit([
|
||||
["3", "three"],
|
||||
["5", "five"],
|
||||
]);
|
||||
write("a.txt", "a2\n");
|
||||
const [only] = headers("long.txt");
|
||||
await runGitAction(REPO, "commit", {
|
||||
message: "block",
|
||||
context: 3,
|
||||
paths: ["a.txt"],
|
||||
hunks: { "long.txt": [{ hunk: only, blocks: [1] }] },
|
||||
});
|
||||
const committed = g("show", "HEAD:long.txt");
|
||||
assert.match(committed, /^3$/m);
|
||||
assert.match(committed, /^five$/m);
|
||||
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
|
||||
assert.match(status(), /^ M long\.txt$/m);
|
||||
assert.doesNotMatch(status(), /a\.txt/);
|
||||
});
|
||||
|
||||
it("refuses a hunk no longer in the diff, an untracked file, and leaves the index alone", async () => {
|
||||
edit([["3", "three"]]);
|
||||
write("new.txt", "n\n");
|
||||
g("add", "b.txt");
|
||||
await assert.rejects(
|
||||
runGitAction(REPO, "commit", {
|
||||
message: "m",
|
||||
hunks: { "long.txt": [{ hunk: "@@ -1,1 +1,1 @@", blocks: [0] }] },
|
||||
}),
|
||||
{ code: "EHUNKGONE" }
|
||||
);
|
||||
await assert.rejects(
|
||||
runGitAction(REPO, "commit", {
|
||||
message: "m",
|
||||
hunks: { "new.txt": [{ hunk: "@@ -0,0 +1 @@", blocks: [0] }] },
|
||||
}),
|
||||
{ code: "EUNTRACKED" }
|
||||
);
|
||||
await assert.rejects(
|
||||
runGitAction(REPO, "commit", {
|
||||
message: "m",
|
||||
hunks: { "long.txt": [{ hunk: headers("long.txt")[0], blocks: ["x"] }] },
|
||||
}),
|
||||
{ code: "EBADPATH" }
|
||||
);
|
||||
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
|
||||
assert.match(status(), /^ M long\.txt$/m);
|
||||
});
|
||||
|
||||
it("amends with only the ticked hunk", async () => {
|
||||
edit([
|
||||
["3", "three"],
|
||||
["25", "twentyfive"],
|
||||
]);
|
||||
const head = g("rev-parse", "HEAD").trim();
|
||||
const [, second] = headers("long.txt");
|
||||
await runGitAction(REPO, "amend", {
|
||||
sha: head,
|
||||
context: 3,
|
||||
paths: [],
|
||||
hunks: { "long.txt": [{ hunk: second, blocks: [0] }] },
|
||||
});
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "long");
|
||||
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
|
||||
const committed = g("show", "HEAD:long.txt");
|
||||
assert.match(committed, /^twentyfive$/m);
|
||||
assert.match(committed, /^3$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blameFile", () => {
|
||||
it("names each line's commit, and none for a line not committed yet", async () => {
|
||||
write("a.txt", "a\nnew line\n");
|
||||
const blame = await blameFile(REPO, "a.txt");
|
||||
assert.equal(blame.lines.length, 2);
|
||||
const first = blame.commits[blame.lines[0]];
|
||||
assert.equal(first.summary, "init");
|
||||
assert.equal(first.author, "t");
|
||||
assert.equal(blame.lines[1], null);
|
||||
});
|
||||
|
||||
it("refuses a path outside the lane and a secret file", async () => {
|
||||
await assert.rejects(blameFile(REPO, "../x"), { code: "EBADPATH" });
|
||||
fs.mkdirSync(path.join(REPO, ".claude"));
|
||||
write(".claude/settings.local.json", "{}\n");
|
||||
g("add", "-f", ".claude/settings.local.json");
|
||||
g("commit", "-qm", "secret");
|
||||
await assert.rejects(blameFile(REPO, ".claude/settings.local.json"), { code: "ESECRET" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a file's history", () => {
|
||||
it("rewrites parents to the file's own commits, so the graph joins up", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
g("commit", "-qam", "a2");
|
||||
write("b.txt", "b2\n");
|
||||
g("commit", "-qam", "b only");
|
||||
write("a.txt", "a3\n");
|
||||
g("commit", "-qam", "a3");
|
||||
const commits = await listCommits(REPO, { file: "a.txt" });
|
||||
assert.deepEqual(
|
||||
commits.map((c) => c.subject),
|
||||
["a3", "a2", "init"]
|
||||
);
|
||||
assert.deepEqual(commits[0].parents, [commits[1].sha]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user