f6eb39eb73
- 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
334 lines
13 KiB
JavaScript
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);
|
|
});
|
|
});
|