1import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/reactivity";
4import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6const state = vi.hoisted(() => ({
7 scopes: new Set<string>(),
8 primaryScopes: new Set<string>(),
9 branch: "main",
10 worktrees: [] as string[],
11 thread: {
12 id: "thread",
13 environmentId: "environment",
14 branch: "main",
15 worktreePath: null as string | null,
16 },
17 commits: 0,
18 pushes: 0,
19 metadataRequests: [] as { environmentId: string; branch: string }[],
20 results: [] as { type: string; description?: string }[],
21 afterGitAction: undefined as (() => void) | undefined,
22 statusRefreshes: 0,
23}));
24
25vi.mock("react", () => ({
26 useCallback: (callback: unknown) => callback,
27 useMemo: (factory: () => unknown) => factory(),
28 useEffect: () => {},
29 useEffectEvent: (callback: unknown) => callback,
30}));
31vi.mock("./session", () => ({
32 useEnvironmentScope: (environmentId: unknown, scope: string) =>
33 (environmentId === state.thread.environmentId ? state.scopes : state.primaryScopes).has(scope),
34 readEnvironmentScope: (environmentId: unknown, scope: string) =>
35 (environmentId === state.thread.environmentId ? state.scopes : state.primaryScopes).has(scope),
36}));
37vi.mock("./use-thread-selection", () => ({
38 useThreadSelection: () => ({
39 selectedThread: state.thread,
40 selectedThreadProject: { workspaceRoot: "/repo" },
41 }),
42}));
43vi.mock("./use-selected-thread-worktree", () => ({
44 useSelectedThreadWorktree: () => ({
45 selectedThreadCwd: "/repo",
46 selectedThreadWorktreePath: null,
47 }),
48}));
49vi.mock("./queries", () => ({
50 useBranches: () => ({ data: { refs: [] }, refresh: () => {} }),
51}));
52vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
53vi.mock("./atom-registry", () => ({ appAtomRegistry: {} }));
54vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} }));
55vi.mock("./use-vcs-action-state", () => ({
56 showGitActionResult: (result: { type: string; description?: string }) =>
57 state.results.push(result),
58}));
59vi.mock("../lib/uuid", () => ({ uuidv4: () => "action" }));
60vi.mock("./threads", () => ({
61 threadEnvironment: {
62 updateMetadata: async ({
63 environmentId,
64 input,
65 }: {
66 environmentId: string;
67 input: { branch: string; worktreePath: string | null };
68 }) => {
69 state.metadataRequests.push({ environmentId, branch: input.branch });
70 if (!state.scopes.has(AuthOrchestrationOperateScope)) {
71 return AsyncResult.failure(Cause.fail(new Error("Task operation denied")));
72 }
73 Object.assign(state.thread, input);
74 return AsyncResult.success(undefined);
75 },
76 },
77}));
78vi.mock("./vcs", () => ({
79 vcsEnvironment: {
80 refreshStatus: async () => {
81 state.statusRefreshes += 1;
82 return AsyncResult.success({ refName: state.branch });
83 },
84 switchRef: async ({ input }: { input: { refName: string } }) => {
85 state.branch = input.refName;
86 state.afterGitAction?.();
87 return AsyncResult.success({ refName: state.branch });
88 },
89 createRef: async ({ input }: { input: { refName: string } }) => {
90 state.branch = input.refName;
91 state.afterGitAction?.();
92 return AsyncResult.success({ refName: state.branch });
93 },
94 createWorktree: async ({ input }: { input: { newRefName: string } }) => {
95 state.worktrees.push("/repo-worktree");
96 state.afterGitAction?.();
97 return AsyncResult.success({
98 worktree: { path: "/repo-worktree", refName: input.newRefName },
99 });
100 },
101 pull: async () => AsyncResult.success({ status: "pulled", refName: state.branch }),
102 },
103 vcsActionManager: {
104 track: (_registry: unknown, _target: unknown, _operation: unknown, run: () => unknown) => run(),
105 runStackedAction: () => async (input: { action: string; featureBranch?: boolean }) => {
106 if (input.featureBranch) state.branch = "feature";
107 if (input.action === "commit") state.commits += 1;
108 if (input.action === "push") state.pushes += 1;
109 state.afterGitAction?.();
110 return AsyncResult.success({
111 branch: input.featureBranch
112 ? { status: "created", name: "feature" }
113 : { status: "skipped_not_requested" },
114 toast: { title: "Done", description: "Done", cta: { kind: "none" } },
115 });
116 },
117 },
118}));
119
120import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions";
121
122describe("thread Git mutation permissions", () => {
123 beforeEach(() => {
124 state.scopes = new Set([AuthSourceControlWriteScope]);
125 state.primaryScopes = new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope]);
126 state.branch = "main";
127 state.worktrees = [];
128 state.thread.branch = "main";
129 state.thread.worktreePath = null;
130 state.commits = 0;
131 state.pushes = 0;
132 state.metadataRequests = [];
133 state.results = [];
134 state.afterGitAction = undefined;
135 state.statusRefreshes = 0;
136 });
137
138 it.each([false, true])(
139 "requires task permission before creating and attaching a worktree: %s",
140 async (canOperate) => {
141 if (canOperate) state.scopes.add(AuthOrchestrationOperateScope);
142 const actions = useSelectedThreadGitActions();
143 const result = await actions.onCreateSelectedThreadWorktree({
144 baseBranch: "main",
145 newBranch: "feature/task",
146 });
147 expect(result).toEqual(
148 canOperate ? { worktree: { path: "/repo-worktree", refName: "feature/task" } } : null,
149 );
150 expect(state.worktrees).toEqual(canOperate ? ["/repo-worktree"] : []);
151 expect(state.thread.worktreePath).toBe(canOperate ? "/repo-worktree" : null);
152 expect(state.thread.branch).toBe(canOperate ? "feature/task" : "main");
153 },
154 );
155
156 it.each(["create", "checkout", "commit on new branch"] as const)(
157 "requires task permission before %s",
158 async (operation) => {
159 const actions = useSelectedThreadGitActions();
160 if (operation === "create") {
161 expect(await actions.onCreateSelectedThreadBranch("feature")).toBeNull();
162 }
163 if (operation === "checkout")
164 expect(await actions.onCheckoutSelectedThreadBranch("feature")).toBeNull();
165 if (operation === "commit on new branch")
166 await actions.onRunSelectedThreadGitAction({ action: "commit", featureBranch: true });
167 expect(state.branch).toBe("main");
168 expect(state.thread.branch).toBe("main");
169 expect(state.commits).toBe(0);
170 },
171 );
172
173 it("rechecks task permission when a retained menu callback runs", async () => {
174 state.scopes.add(AuthOrchestrationOperateScope);
175 const actions = useSelectedThreadGitActions();
176 state.scopes.delete(AuthOrchestrationOperateScope);
177 expect(
178 await actions.onCreateSelectedThreadWorktree({
179 baseBranch: "main",
180 newBranch: "feature/task",
181 }),
182 ).toBeNull();
183 expect(state.worktrees).toEqual([]);
184 expect(state.thread.worktreePath).toBeNull();
185 });
186
187 it("keeps ordinary commits and pushes available without task permission", async () => {
188 const actions = useSelectedThreadGitActions();
189 await actions.onRunSelectedThreadGitAction({ action: "commit" });
190 await actions.onRunSelectedThreadGitAction({ action: "push" });
191 expect(state.commits).toBe(1);
192 expect(state.pushes).toBe(1);
193 expect(state.thread.branch).toBe("main");
194 expect(state.metadataRequests).toEqual([]);
195 });
196
197 it.each(["create", "checkout", "worktree", "commit"] as const)(
198 "does not send thread metadata after task permission is revoked during %s",
199 async (operation) => {
200 state.scopes.add(AuthOrchestrationOperateScope);
201 state.afterGitAction = () => state.scopes.delete(AuthOrchestrationOperateScope);
202 const actions = useSelectedThreadGitActions();
203 if (operation === "create")
204 expect(await actions.onCreateSelectedThreadBranch("feature")).toBeNull();
205 if (operation === "checkout")
206 expect(await actions.onCheckoutSelectedThreadBranch("feature")).toBeNull();
207 if (operation === "worktree")
208 expect(
209 await actions.onCreateSelectedThreadWorktree({
210 baseBranch: "main",
211 newBranch: "feature",
212 }),
213 ).toBeNull();
214 if (operation === "commit")
215 expect(
216 await actions.onRunSelectedThreadGitAction({ action: "commit", featureBranch: true }),
217 ).toBeNull();
218 if (operation === "worktree") expect(state.worktrees).toEqual(["/repo-worktree"]);
219 else expect(state.branch).toBe("feature");
220 expect(state.thread.branch).toBe("main");
221 expect(state.thread.worktreePath).toBeNull();
222 expect(state.metadataRequests).toEqual([]);
223 expect(state.results.map((result) => result.type)).toEqual(["error"]);
224 },
225 );
226
227 it("refreshes the worktree status when the metadata update is denied after checkout", async () => {
228 state.scopes.add(AuthOrchestrationOperateScope);
229 // Git already switched the branch, so the sheet must show it even though the
230 // thread record could not be updated.
231 state.afterGitAction = () => state.scopes.delete(AuthOrchestrationOperateScope);
232 expect(
233 await useSelectedThreadGitActions().onCheckoutSelectedThreadBranch("feature"),
234 ).toBeNull();
235 expect(state.branch).toBe("feature");
236 expect(state.statusRefreshes).toBe(1);
237 });
238
239 it("uses a newly granted task permission for a retained branch callback", async () => {
240 state.primaryScopes.clear();
241 const actions = useSelectedThreadGitActions();
242 state.scopes.add(AuthOrchestrationOperateScope);
243 await actions.onCreateSelectedThreadBranch("feature");
244 expect(state.thread.branch).toBe("feature");
245 expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]);
246 });
247
248 it.each(["create", "checkout", "worktree"] as const)(
249 "returns the accepted %s result when only source-control permission is revoked after Git",
250 async (operation) => {
251 state.scopes.add(AuthOrchestrationOperateScope);
252 state.afterGitAction = () => state.scopes.delete(AuthSourceControlWriteScope);
253 const actions = useSelectedThreadGitActions();
254 const result =
255 operation === "create"
256 ? await actions.onCreateSelectedThreadBranch("feature/task")
257 : operation === "checkout"
258 ? await actions.onCheckoutSelectedThreadBranch("feature/task")
259 : await actions.onCreateSelectedThreadWorktree({
260 baseBranch: "main",
261 newBranch: "feature/task",
262 });
263 expect(result).toEqual(
264 operation === "worktree"
265 ? { worktree: { path: "/repo-worktree", refName: "feature/task" } }
266 : { refName: "feature/task" },
267 );
268 expect(state.thread.branch).toBe("feature/task");
269 expect(state.metadataRequests).toHaveLength(1);
270 expect(state.results).toEqual([]);
271 },
272 );
273
274 it("returns a successful checkout when the requested branch is already selected", async () => {
275 state.scopes.add(AuthOrchestrationOperateScope);
276
277 expect(await useSelectedThreadGitActions().onCheckoutSelectedThreadBranch("main")).toEqual({
278 refName: "main",
279 });
280 expect(state.branch).toBe("main");
281 expect(state.thread.branch).toBe("main");
282 expect(state.results).toEqual([]);
283 });
284});