Files
AgentHub/server/__tests__/git-ops-shelves.test.js
T
nntrivi2001 f6eb39eb73 feat(git): switch with uncommitted changes (carry / shelve), Create & switch, and shelves with Restore/Delete
- switch takes changes: carry (plain git switch, 409 ECARRYCONFLICT with files) or shelve (shelf first, re-applied if the switch fails)
- branch-create switch: true runs git switch -c, the changes come along
- shelve / unshelve / shelf-drop + GET /git/shelves: only agenthub-shelf: stash entries, addressed by sha, repo root only, never mid-operation; a clean restore drops the shelf, a conflicted one keeps it
- 409 EAGENTRUNNING until confirm: true while the workspace's agent is working
- git bar: Bring my changes / Shelve and switch choice, Create & switch, restore offer after a switch; Changes panel: Shelve (n) and the Shelves list
- fix: after a switch the Changes panel showed the old branch — the post-write re-read joined an in-flight GET started before the write (request() dedupe); writes now close in-flight GETs, and every git write fires agenthub:git-changed so every view of the workspace re-reads
2026-11-02 11:44:35 +07:00

334 lines
13 KiB
JavaScript

/**
* @file Shelves and switching with uncommitted changes (git-ops.js), against
* REAL temp repos: create & switch, switch carrying the changes (and the
* ECARRYCONFLICT refusal with git's file list), shelve & switch (and the
* shelf going straight back when the switch fails), shelve / unshelve /
* shelf-drop with their guards (repo root, no merge in progress, a clean tree
* for a restore, confirm for a delete, a working agent's confirm), sha
* addressing, and that a stash AgentHub did not make is never listed or
* touched.
* @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, listShelves } = require("../lib/git-ops");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-shelves-"));
const REPO = path.join(ROOT, "repo");
const g = (...args) => {
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
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 exists = (file) => fs.existsSync(path.join(REPO, file));
const branch = () => g("branch", "--show-current").trim();
const status = () => g("status", "--porcelain").trim();
const act = (action, body, opts) => runGitAction(REPO, action, body, opts);
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");
fs.mkdirSync(path.join(REPO, "sub"));
write("a.txt", "base\n");
write("shared.txt", "base\n");
write("sub/s.txt", "s\n");
g("add", "-A");
g("commit", "-qm", "init");
// `feat` changes shared.txt, so carrying an edit of it across is refused.
g("switch", "-qc", "feat");
write("shared.txt", "feat\n");
g("commit", "-qam", "feat");
g("switch", "-q", "main");
});
describe("branch-create with switch", () => {
it("creates the branch, moves to it and carries the uncommitted changes", async () => {
write("a.txt", "wip\n");
write("new.txt", "n\n");
const r = await act("branch-create", { branch: "fix/x", switch: true });
assert.equal(r.branch, "fix/x");
assert.equal(branch(), "fix/x");
assert.equal(read("a.txt"), "wip\n");
assert.equal(read("new.txt"), "n\n");
});
it("plain create still never switches", async () => {
await act("branch-create", { branch: "side" });
assert.equal(branch(), "main");
});
it("is EMERGING while a merge is open", async () => {
write("shared.txt", "main\n");
g("commit", "-qam", "main");
await act("merge", { branch: "feat" });
await assert.rejects(act("branch-create", { branch: "y", switch: true }), {
code: "EMERGING",
});
});
});
describe("switch with changes", () => {
it("still refuses a dirty tree without `changes`, and an unknown value", async () => {
write("a.txt", "wip\n");
await assert.rejects(act("switch", { branch: "feat" }), { code: "EDIRTY" });
await assert.rejects(act("switch", { branch: "feat", changes: "force" }), {
code: "EBADCHANGES",
});
});
it("carry brings the changes along", async () => {
write("a.txt", "wip\n");
const r = await act("switch", { branch: "feat", changes: "carry" });
assert.equal(branch(), "feat");
assert.equal(read("a.txt"), "wip\n");
assert.deepEqual(r.shelves, []);
});
it("carry answers ECARRYCONFLICT with the files git would overwrite", async () => {
write("shared.txt", "mine\n");
await assert.rejects(act("switch", { branch: "feat", changes: "carry" }), (e) => {
assert.equal(e.code, "ECARRYCONFLICT");
assert.deepEqual(e.files, ["shared.txt"]);
return true;
});
assert.equal(branch(), "main");
assert.equal(read("shared.txt"), "mine\n");
});
it("shelve sets the changes aside, switches, and the shelf comes back on the way home", async () => {
write("shared.txt", "mine\n");
write("new.txt", "n\n");
const r = await act("switch", { branch: "feat", changes: "shelve", note: "half done" });
assert.equal(branch(), "feat");
assert.equal(status(), "");
assert.equal(r.shelved.branch, "main");
assert.equal(r.shelved.note, "half done");
assert.deepEqual(r.shelves, []);
const back = await act("switch", { branch: "main" });
assert.equal(back.shelves.length, 1);
assert.equal(back.shelves[0].sha, r.shelved.sha);
const u = await act("unshelve", { sha: r.shelved.sha });
assert.equal(u.kept, false);
assert.deepEqual(u.conflicts, []);
assert.equal(read("shared.txt"), "mine\n");
assert.equal(read("new.txt"), "n\n");
assert.deepEqual(await listShelves(REPO), []);
});
it("refuses before shelving when the target cannot be switched to", async () => {
write("a.txt", "wip\n");
await assert.rejects(act("switch", { branch: "nope", changes: "shelve" }), {
code: "ENOBRANCH",
});
assert.deepEqual(await listShelves(REPO), []);
assert.equal(read("a.txt"), "wip\n");
});
it("leaves nothing moved when the shelf cannot even be made", async () => {
write("a.txt", "wip\n");
fs.writeFileSync(path.join(REPO, ".git", "index.lock"), "");
try {
await assert.rejects(act("switch", { branch: "feat", changes: "shelve" }));
} finally {
fs.rmSync(path.join(REPO, ".git", "index.lock"), { force: true });
}
assert.equal(branch(), "main");
assert.equal(read("a.txt"), "wip\n");
assert.deepEqual(await listShelves(REPO), []);
});
it("puts the shelf straight back when the switch itself fails", async () => {
write("a.txt", "wip\n");
write("new.txt", "n\n");
// A branch only the remote has: the switch must create refs/heads/rfeat,
// and a stale lock on it makes git refuse AFTER the shelf was made.
g("remote", "add", "origin", path.join(ROOT, "nowhere.git"));
g("update-ref", "refs/remotes/origin/rfeat", "feat");
fs.writeFileSync(path.join(REPO, ".git", "refs", "heads", "rfeat.lock"), "");
try {
await assert.rejects(act("switch", { branch: "origin/rfeat", changes: "shelve" }), (e) => {
assert.notEqual(e.code, "ESHELVED");
return true;
});
} finally {
fs.rmSync(path.join(REPO, ".git", "refs", "heads", "rfeat.lock"), { force: true });
}
assert.equal(branch(), "main");
assert.equal(read("a.txt"), "wip\n");
assert.equal(read("new.txt"), "n\n");
assert.deepEqual(await listShelves(REPO), []);
});
it("restores a shelf whose stash failed half way (HEAD locked)", async () => {
write("a.txt", "wip\n");
fs.writeFileSync(path.join(REPO, ".git", "HEAD.lock"), "");
try {
await assert.rejects(act("switch", { branch: "feat", changes: "shelve" }));
} finally {
fs.rmSync(path.join(REPO, ".git", "HEAD.lock"), { force: true });
}
assert.equal(branch(), "main");
assert.equal(read("a.txt"), "wip\n");
assert.deepEqual(await listShelves(REPO), []);
});
it("asks for confirm while the lane's agent is working", async () => {
write("a.txt", "wip\n");
await assert.rejects(
act("switch", { branch: "feat", changes: "carry" }, { agentWorking: true }),
{ code: "EAGENTRUNNING" }
);
await act(
"switch",
{ branch: "feat", changes: "carry", confirm: true },
{ agentWorking: true }
);
assert.equal(branch(), "feat");
});
});
describe("shelves", () => {
it("shelves everything (untracked included) with a note, and lists it", async () => {
write("a.txt", "wip\n");
write("new.txt", "n\n");
const r = await act("shelve", { note: "try\nagain" });
assert.equal(status(), "");
assert.equal(r.shelf.note, "try again");
const [s] = await listShelves(REPO);
assert.equal(s.sha, r.shelf.sha);
assert.equal(s.branch, "main");
assert.match(s.ref, /^stash@\{0\}$/);
assert.ok(!Number.isNaN(Date.parse(s.createdAt)));
assert.deepEqual(s.files.map((f) => `${f.status} ${f.path}`).sort(), ["?? new.txt", "M a.txt"]);
});
it("shelves only the ticked paths", async () => {
write("a.txt", "wip\n");
write("sub/s.txt", "changed\n");
write("new*.txt", "glob-looking name\n");
write("new1.txt", "untouched untracked\n");
const r = await act("shelve", { paths: ["a.txt", "new*.txt"] });
assert.deepEqual(
r.shelf.files.map((f) => f.path),
["a.txt", "new*.txt"]
);
assert.equal(read("a.txt"), "base\n");
// Literal: `new*.txt` took itself only, never new1.txt.
assert.equal(exists("new*.txt"), false);
assert.equal(read("new1.txt"), "untouched untracked\n");
assert.equal(read("sub/s.txt"), "changed\n");
await assert.rejects(act("shelve", { paths: ["../x"] }), { code: "EBADPATH" });
});
it("refuses a clean tree, a subdirectory lane, and a merge in progress", async () => {
await assert.rejects(act("shelve", {}), { code: "ENOCHANGES" });
write("a.txt", "wip\n");
await assert.rejects(runGitAction(path.join(REPO, "sub"), "shelve", {}), {
code: "ESUBDIR",
});
g("checkout", "--", "a.txt");
write("shared.txt", "main\n");
g("commit", "-qam", "main");
await act("merge", { branch: "feat" });
await assert.rejects(act("shelve", {}), { code: "EMERGING" });
});
it("restores staged changes staged, and refuses a dirty tree", async () => {
write("a.txt", "staged\n");
g("add", "a.txt");
const { shelf } = await act("shelve", {});
write("sub/s.txt", "new work\n");
await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" });
g("checkout", "--", "sub/s.txt");
await act("unshelve", { sha: shelf.sha });
assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt");
});
it("keeps a conflicted shelf and lets `resolve` settle its files", async () => {
write("shared.txt", "mine\n");
const { shelf } = await act("shelve", {});
write("shared.txt", "theirs on main\n");
g("commit", "-qam", "moved on");
const r = await act("unshelve", { sha: shelf.sha });
assert.equal(r.kept, true);
assert.deepEqual(r.conflicts, ["shared.txt"]);
assert.equal((await listShelves(REPO)).length, 1);
// `theirs` of a stash apply is the shelf.
await act("resolve", { path: "shared.txt", side: "theirs" });
assert.equal(read("shared.txt"), "mine\n");
assert.equal(g("diff", "--name-only", "--diff-filter=U").trim(), "");
// The kept shelf is deleted by hand.
await act("shelf-drop", { sha: shelf.sha, confirm: true });
assert.deepEqual(await listShelves(REPO), []);
});
it("shelf-drop needs confirm, and a sha no shelf has is ENOSHELF", async () => {
write("a.txt", "wip\n");
const { shelf } = await act("shelve", {});
await assert.rejects(act("shelf-drop", { sha: shelf.sha }), { code: "ECONFIRM" });
await assert.rejects(act("shelf-drop", { sha: "deadbeef", confirm: true }), {
code: "ENOSHELF",
});
await assert.rejects(act("unshelve", { sha: "not a sha" }), { code: "EBADSHA" });
assert.equal((await listShelves(REPO)).length, 1);
});
it("addresses a shelf by sha even after another stash shifted its index", async () => {
write("a.txt", "first\n");
const first = (await act("shelve", { note: "one" })).shelf;
write("sub/s.txt", "second\n");
const second = (await act("shelve", { note: "two" })).shelf;
// A user's own stash pushed on top, from a terminal.
write("shared.txt", "user\n");
g("stash", "push", "-q", "-m", "my own stash");
const listed = await listShelves(REPO);
assert.deepEqual(
listed.map((s) => s.note),
["two", "one"]
);
assert.equal(listed[1].ref, "stash@{2}");
await act("unshelve", { sha: first.sha.slice(0, 10) });
assert.equal(read("a.txt"), "first\n");
assert.equal(read("sub/s.txt"), "s\n");
assert.deepEqual(
(await listShelves(REPO)).map((s) => s.sha),
[second.sha]
);
// The user's stash is untouched and never listed.
assert.match(g("stash", "list"), /my own stash/);
await assert.rejects(
act("shelf-drop", {
sha: g("rev-parse", "stash@{0}").trim(),
confirm: true,
}),
{ code: "ENOSHELF" }
);
});
it("asks for confirm before shelving or restoring under a working agent", async () => {
write("a.txt", "wip\n");
await assert.rejects(act("shelve", {}, { agentWorking: true }), { code: "EAGENTRUNNING" });
const { shelf } = await act("shelve", { confirm: true }, { agentWorking: true });
await assert.rejects(act("unshelve", { sha: shelf.sha }, { agentWorking: true }), {
code: "EAGENTRUNNING",
});
await act("unshelve", { sha: shelf.sha, confirm: true }, { agentWorking: true });
assert.equal(read("a.txt"), "wip\n");
assert.equal(exists("a.txt"), true);
});
});