/** * @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ĩ */ 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 one of its files changed", async () => { write("a.txt", "staged\n"); g("add", "a.txt"); const { shelf } = await act("shelve", {}); write("a.txt", "new work\n"); await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" }); g("checkout", "--", "a.txt"); await act("unshelve", { sha: shelf.sha }); assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt"); }); it("restores beside changes in other files, never over its own or a staged index", async () => { // A partial Shelve (n): the unticked files stay behind and must not block // the restore. write("a.txt", "wip\n"); write("sub/new.txt", "new\n"); write("left.txt", "left behind\n"); const { shelf } = await act("shelve", { paths: ["a.txt", "sub/new.txt"] }); assert.equal(read("left.txt"), "left behind\n"); // Its own file changed since: refused, with the file named. write("a.txt", "other\n"); await assert.rejects(act("unshelve", { sha: shelf.sha }), (e) => { assert.equal(e.code, "EDIRTY"); assert.deepEqual(e.paths, ["a.txt"]); return true; }); g("checkout", "--", "a.txt"); // Anything staged: refused, nothing written. write("b.txt", "staged\n"); g("add", "b.txt"); await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" }); assert.equal(fs.existsSync(path.join(REPO, "sub/new.txt")), false); g("rm", "-q", "--cached", "b.txt"); const r = await act("unshelve", { sha: shelf.sha }); assert.equal(r.kept, false); assert.equal(read("a.txt"), "wip\n"); assert.equal(read("sub/new.txt"), "new\n"); assert.equal(read("left.txt"), "left behind\n"); assert.equal(read("b.txt"), "staged\n"); assert.equal((await listShelves(REPO)).length, 0); }); 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); }); });