Files
AgentHub/client/src/lib/__tests__/gitWriteFreshness.test.ts
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

91 lines
3.2 KiB
TypeScript

/**
* @file Pins the cause of "after Switch branch the Changes panel still shows
* the old branch": `request()` shares one in-flight GET between callers, and
* the panel's re-read after a switch joined a poll that had STARTED before
* the switch finished — so it got the old branch's answer. A write now
* closes every in-flight GET to new joiners (before it is sent and once it
* settles), and `gitAction` announces itself (`agenthub:git-changed`) so the
* views that never hear the bar's callback read again too.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { api } from "../api";
import { GIT_CHANGED_EVENT } from "../gitChanged";
afterEach(() => {
vi.unstubAllGlobals();
});
function json(body: unknown) {
return {
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: async () => body,
};
}
describe("a git write and the reads around it", () => {
it("a read asked after a switch never joins a read started before it", async () => {
let releaseOld: (v: unknown) => void = () => {};
const spy = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === "POST") return Promise.resolve(json({ ok: true, action: "switch" }));
// The first GET (a poll) hangs until released, answering the OLD branch.
if (spy.mock.calls.filter((c) => !c[1]?.method).length === 1) {
return new Promise((r) => {
releaseOld = () => r(json({ branch: "old" }));
});
}
return Promise.resolve(json({ branch: "new" }));
});
vi.stubGlobal("fetch", spy);
const poll = api.lanes.git(3);
await api.lanes.gitAction(3, "switch", { branch: "new" });
const after = api.lanes.git(3);
releaseOld(undefined);
expect(await poll).toEqual({ branch: "old" });
expect(await after).toEqual({ branch: "new" });
expect(spy.mock.calls.filter((c) => !c[1]?.method)).toHaveLength(2);
});
it("still shares a read between callers when no write came between them", async () => {
const spy = vi.fn(() => Promise.resolve(json({ branch: "x" })));
vi.stubGlobal("fetch", spy);
await Promise.all([api.lanes.git(4), api.lanes.git(4)]);
expect(spy).toHaveBeenCalledTimes(1);
});
it("announces every git write for its workspace, a failed one included", async () => {
const seen: unknown[] = [];
const on = (e: Event) => seen.push((e as CustomEvent).detail);
window.addEventListener(GIT_CHANGED_EVENT, on);
try {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(json({ ok: true })))
);
await api.lanes.gitAction({ lane: 5, session: "s" }, "shelve", {});
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: false,
status: 409,
headers: { get: () => "application/json" },
json: async () => ({ error: { code: "ESHELVED", message: "x" } }),
})
)
);
await expect(api.lanes.gitAction(5, "switch", { branch: "b" })).rejects.toThrow();
} finally {
window.removeEventListener(GIT_CHANGED_EVENT, on);
}
expect(seen).toEqual([
{ laneId: 5, action: "shelve" },
{ laneId: 5, action: "switch" },
]);
});
});