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:
+179
-11
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
|
||||
* stage/unstage a file, commit, discard a file, switch branch, create or
|
||||
* stage/unstage a file, commit (the index, everything, or exactly the ticked
|
||||
* `paths`), discard a file, add an untracked file to the workspace's
|
||||
* .gitignore (`ignore`), switch branch, create or
|
||||
* delete a branch, publish a repo that has no remote yet, and merge a branch
|
||||
* with its conflict resolution (issue #45: merge, resolve ours/theirs/
|
||||
* hand-edited, merge-commit, merge-abort). There is no rebase, no hard reset
|
||||
@@ -59,7 +61,7 @@ const {
|
||||
operationInProgress,
|
||||
branchRef,
|
||||
} = require("./worktree");
|
||||
const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
|
||||
const { hunkPatch, selectionPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
|
||||
const gitCredentials = require("./git-credentials");
|
||||
|
||||
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
|
||||
@@ -91,6 +93,7 @@ const ACTIONS = new Set([
|
||||
"revert",
|
||||
"undo-commit",
|
||||
"amend",
|
||||
"ignore",
|
||||
]);
|
||||
|
||||
/** Actions that would tangle with a merge, cherry-pick or revert in progress. */
|
||||
@@ -476,6 +479,11 @@ async function runGitAction(dir, action, body = {}) {
|
||||
// would switch it to committing the working-tree copy (unstaged edits
|
||||
// included) — so a subdirectory lane refuses rather than sweep another
|
||||
// actor's staged work outside it into this lane's commit.
|
||||
// `paths` (the panel's ticked files): exactly those, whatever the index
|
||||
// holds — `--only` leaves every other change, staged or not, outside.
|
||||
if (hasHunks(body)) return done(action, await commitSelection(dir, ["-m", message], body));
|
||||
if (Array.isArray(body.paths))
|
||||
return done(action, await commitPaths(dir, ["-m", message], body));
|
||||
await assertNothingStagedOutside(dir);
|
||||
if (body.only === "staged") {
|
||||
// The panel's per-file staging only means something if a commit can
|
||||
@@ -497,6 +505,8 @@ async function runGitAction(dir, action, body = {}) {
|
||||
return done(action, await git(dir, ["commit", "-m", message]));
|
||||
}
|
||||
|
||||
if (action === "ignore") return ignorePaths(dir, body);
|
||||
|
||||
if (action === "branch-create" || action === "branch-delete") {
|
||||
return branchAction(dir, action, body);
|
||||
}
|
||||
@@ -627,7 +637,9 @@ async function commitSha(dir, sha) {
|
||||
|
||||
/** How many parents a commit has (0 = root, 2+ = a merge commit). */
|
||||
async function parentCount(dir, sha) {
|
||||
return (await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ").length - 1;
|
||||
return (
|
||||
(await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ").length - 1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -646,7 +658,13 @@ async function assertUnpushedHead(dir, sha) {
|
||||
throw bad("ESUBDIR", "rewriting a commit moves the whole branch; use the repo-root workspace");
|
||||
}
|
||||
const on = (
|
||||
await git(dir, ["for-each-ref", "--contains", "HEAD", "--format=%(refname:short)", "refs/remotes"])
|
||||
await git(dir, [
|
||||
"for-each-ref",
|
||||
"--contains",
|
||||
"HEAD",
|
||||
"--format=%(refname:short)",
|
||||
"refs/remotes",
|
||||
])
|
||||
).stdout.trim();
|
||||
if (on) {
|
||||
throw bad("EPUSHED", `the last commit is already on ${on.split("\n")[0]}; revert it instead`);
|
||||
@@ -670,7 +688,8 @@ async function historyAction(dir, action, body) {
|
||||
const open = await operationInProgress(dir);
|
||||
const conflicts = open ? await unmergedPaths(dir) : [];
|
||||
// Conflicts: an operation that STARTED, finished from the banner.
|
||||
if (conflicts.length) return { ...done(action, e.git || { stdout: "", stderr: "" }), conflicts };
|
||||
if (conflicts.length)
|
||||
return { ...done(action, e.git || { stdout: "", stderr: "" }), conflicts };
|
||||
// Nothing to resolve, so nothing may be left behind either: a pick
|
||||
// whose changes are already here (EEMPTY), or a commit git could not
|
||||
// make (no identity, a failing signer) — which can leave the changes
|
||||
@@ -687,8 +706,14 @@ async function historyAction(dir, action, body) {
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
if (touched.length) {
|
||||
await git(dir, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...touched])
|
||||
.catch(() => {});
|
||||
await git(dir, [
|
||||
"restore",
|
||||
"--source=HEAD",
|
||||
"--staged",
|
||||
"--worktree",
|
||||
"--",
|
||||
...touched,
|
||||
]).catch(() => {});
|
||||
}
|
||||
if (empty) throw bad("EEMPTY", "those changes are already on this branch");
|
||||
throw e;
|
||||
@@ -709,19 +734,162 @@ async function historyAction(dir, action, body) {
|
||||
// amend: the index as it stands folds into the last commit, like `commit`
|
||||
// with `only: "staged"`; no message keeps the old one.
|
||||
await assertUnpushedHead(dir, body.sha);
|
||||
await assertNothingStagedOutside(dir);
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long");
|
||||
const msgArgs = message ? ["-m", message] : ["--no-edit"];
|
||||
if (hasHunks(body)) {
|
||||
return done(action, await commitSelection(dir, ["--amend", ...msgArgs], body));
|
||||
}
|
||||
if (Array.isArray(body.paths)) {
|
||||
// No ticked file: `--only` with no path rewords and leaves the index out.
|
||||
if (!body.paths.length) {
|
||||
if (!message) throw bad("ENOCHANGES", "no file ticked and no new message to amend with");
|
||||
return done(action, await git(dir, ["commit", "--amend", "--only", ...msgArgs]));
|
||||
}
|
||||
return done(action, await commitPaths(dir, ["--amend", ...msgArgs], body));
|
||||
}
|
||||
await assertNothingStagedOutside(dir);
|
||||
if (!message) {
|
||||
const staged = [...(await changedPaths(dir)).values()].some(
|
||||
({ code }) => code[0] !== " " && code[0] !== "?"
|
||||
);
|
||||
if (!staged) throw bad("ENOCHANGES", "nothing staged and no new message to amend with");
|
||||
}
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["commit", "--amend", ...(message ? ["-m", message] : ["--no-edit"])])
|
||||
return done(action, await git(dir, ["commit", "--amend", ...msgArgs]));
|
||||
}
|
||||
|
||||
/**
|
||||
* `git commit --only -- <paths>`: the working-tree content of exactly the
|
||||
* paths the request ticked (JetBrains' commit), each one `git status` reports
|
||||
* changed. An untracked one is added first — `--only` takes only paths git
|
||||
* knows — and taken back out of the index if the commit then fails.
|
||||
*/
|
||||
async function commitPaths(dir, args, body) {
|
||||
const paths = await resolvePaths(dir, body);
|
||||
const conflicted = paths.find((p) => CONFLICT_CODES.has(p.code));
|
||||
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
|
||||
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
|
||||
// A rename's original path rides along, or its deletion stays behind.
|
||||
const all = paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
|
||||
if (untracked.length) await git(dir, ["add", "--", ...untracked]);
|
||||
try {
|
||||
return await git(dir, ["commit", ...args, "--only", "--", ...all]);
|
||||
} catch (e) {
|
||||
if (untracked.length)
|
||||
await git(dir, ["rm", "--cached", "-q", "--", ...untracked]).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the request names change blocks (`hunks`), not just whole files. */
|
||||
function hasHunks(body) {
|
||||
return !!body.hunks && typeof body.hunks === "object" && Object.keys(body.hunks).length > 0;
|
||||
}
|
||||
|
||||
/** Most files one hunk-level commit may name partially. */
|
||||
const MAX_PARTIAL_FILES = 300;
|
||||
|
||||
/**
|
||||
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
|
||||
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
|
||||
* the hunks named as `stage-hunk` names them, at `context`. Built in a
|
||||
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
|
||||
* `selectionPatch`, git's own fresh text, never the request's) and committed
|
||||
* from it, so the real index and the working tree are never in the way; after
|
||||
* the commit the real index entries of exactly those paths are reset to the
|
||||
* new HEAD, the rest of each partial file left as an unstaged change. Same
|
||||
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
|
||||
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
|
||||
*/
|
||||
async function commitSelection(dir, args, body) {
|
||||
const picks = Object.entries(body.hunks);
|
||||
if (picks.length > MAX_PARTIAL_FILES) throw bad("EBADPATH", "too many files");
|
||||
for (const [, list] of picks) {
|
||||
const ok =
|
||||
Array.isArray(list) &&
|
||||
list.length > 0 &&
|
||||
list.every(
|
||||
(x) =>
|
||||
x &&
|
||||
typeof x.hunk === "string" &&
|
||||
Array.isArray(x.blocks) &&
|
||||
x.blocks.length > 0 &&
|
||||
x.blocks.every((b) => Number.isInteger(b) && b >= 0)
|
||||
);
|
||||
if (!ok) throw bad("EBADPATH", "hunks: each file needs [{hunk, blocks: [n, …]}]");
|
||||
}
|
||||
const whole = Array.isArray(body.paths) && body.paths.length ? await resolvePaths(dir, body) : [];
|
||||
const part = await resolvePaths(dir, { paths: picks.map(([p]) => p) });
|
||||
const conflicted = [...whole, ...part].find((p) => CONFLICT_CODES.has(p.code));
|
||||
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
|
||||
for (const p of part) {
|
||||
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: commit it whole`);
|
||||
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: commit it whole`);
|
||||
if (whole.some((w) => w.path === p.path)) {
|
||||
throw bad("EBADPATH", `${p.path} is named both whole and in part`);
|
||||
}
|
||||
}
|
||||
const wholePaths = whole.flatMap((p) =>
|
||||
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
|
||||
);
|
||||
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-commit-"));
|
||||
const env = { GIT_INDEX_FILE: path.join(tmp, "index") };
|
||||
try {
|
||||
await git(dir, (await hasHead(dir)) ? ["read-tree", "HEAD"] : ["read-tree", "--empty"], {
|
||||
env,
|
||||
});
|
||||
if (wholePaths.length) await git(dir, ["add", "-A", "--", ...wholePaths], { env });
|
||||
const file = path.join(tmp, "part.patch");
|
||||
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
|
||||
for (const [p, list] of picks) {
|
||||
const patch = await selectionPatch(dir, p, body.context, list);
|
||||
if (!patch)
|
||||
throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`);
|
||||
await fs.promises.writeFile(file, patch);
|
||||
// The patch names paths from the repo root, so it is applied there.
|
||||
await git(top, ["apply", "--cached", ...zero, file], { env });
|
||||
}
|
||||
const out = await git(dir, ["commit", ...args], { env });
|
||||
await git(dir, ["reset", "-q", "--", ...wholePaths, ...part.map((p) => p.path)]).catch(
|
||||
() => {}
|
||||
);
|
||||
return out;
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** A path as one literal .gitignore line, anchored at the file's folder. */
|
||||
function ignoreLine(p) {
|
||||
return `/${p.replace(/[\\*?[]/g, "\\$&").replace(/ $/, "\\ ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to .gitignore: one anchored line per path in the WORKSPACE's own
|
||||
* .gitignore (its cwd — a subdirectory workspace never writes above itself),
|
||||
* only for a path `git status` lists as untracked, never a line it already
|
||||
* has, and never through a symlink or onto anything but a regular file.
|
||||
*/
|
||||
async function ignorePaths(dir, body) {
|
||||
const paths = await resolvePaths(dir, body);
|
||||
const tracked = paths.find((p) => p.code !== "??");
|
||||
if (tracked)
|
||||
throw bad("ETRACKED", `${tracked.path} is tracked: only an untracked file can be ignored`);
|
||||
// A line break in a name would write a second, arbitrary pattern.
|
||||
if (paths.some((p) => /[\r\n]/.test(p.path)))
|
||||
throw bad("EBADPATH", "a path with a line break cannot be ignored");
|
||||
const file = path.join(dir, ".gitignore");
|
||||
const st = await fs.promises.lstat(file).catch(() => null);
|
||||
if (st && !st.isFile()) throw bad("EBADPATH", ".gitignore is not a regular file");
|
||||
const old = st ? await fs.promises.readFile(file, "utf8") : "";
|
||||
const have = new Set(old.split(/\r?\n/));
|
||||
const lines = [...new Set(paths.map((p) => ignoreLine(p.path)))].filter((l) => !have.has(l));
|
||||
if (lines.length) {
|
||||
const sep = old && !old.endsWith("\n") ? "\n" : "";
|
||||
await fs.promises.appendFile(file, `${sep}${lines.join("\n")}\n`);
|
||||
}
|
||||
return done("ignore", { stdout: lines.map((l) => `${l}\n`).join(""), stderr: "" });
|
||||
}
|
||||
|
||||
/** Create or delete a branch WITHOUT touching the working tree. */
|
||||
|
||||
Reference in New Issue
Block a user