Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/mobile/src/state/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useState } from "react";

import { appAtomRegistry } from "./atom-registry";
import { environmentCatalog } from "../connection/catalog";
import { orchestrationEnvironment } from "./orchestration";
import { projectEnvironment } from "./projects";
import { useEnvironmentQuery } from "./query";
Expand Down Expand Up @@ -138,9 +139,13 @@ export function useBranches(input: {

export function usePaginatedBranches(target: VcsRefTarget) {
const query = target.query?.trim() ?? "";
const connection = useEnvironmentQuery(
target.environmentId === null ? null : environmentCatalog.stateAtom(target.environmentId),
).data;
const connectionGeneration = connection?.phase === "connected" ? connection.generation : null;
const targetKey =
target.environmentId !== null && target.cwd !== null
? JSON.stringify([target.environmentId, target.cwd, query])
? JSON.stringify([target.environmentId, target.cwd, query, connectionGeneration])
: null;
const [pagination, setPagination] = useState<{
readonly targetKey: string | null;
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ describe("RPC authorization scopes", () => {
);
});

it("allows Git history reads with the orchestration read scope", () => {
expect(requiredScopeForRpcMethod(WS_METHODS.vcsListHistoryRefs)).toBe(
AuthOrchestrationReadScope,
);
expect(requiredScopeForRpcMethod(WS_METHODS.vcsGetHistory)).toBe(AuthOrchestrationReadScope);
expect(requiredScopeForRpcMethod(WS_METHODS.vcsGetCommitDetails)).toBe(
AuthOrchestrationReadScope,
);
});

it("rejects unknown RPC method names", () => {
for (const method of ["server.notRegistered", "toString", "constructor"]) {
expect(() => requiredScopeForRpcMethod(method)).toThrow(
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope,
[WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
[WS_METHODS.vcsListHistoryRefs]: AuthOrchestrationReadScope,
[WS_METHODS.vcsGetHistory]: AuthOrchestrationReadScope,
[WS_METHODS.vcsGetCommitDetails]: AuthOrchestrationReadScope,
[WS_METHODS.vcsListCommitFiles]: AuthOrchestrationReadScope,
[WS_METHODS.vcsGetCommitDiff]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.attachmentUploads).toBe(true);
expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
expect(second.capabilities.pullRequests).toBe(true);
expect(second.capabilities.gitHistory).toBe(true);
expect(second.capabilities.usagePriceOverrides).toBe(true);
expect(second.capabilities.threadActiveReorder).toBe(true);
expect(second.capabilities.threadTitleRegeneration).toBe(true);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ export const make = Effect.gen(function* () {
fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
pullRequests: true,
issues: true,
gitHistory: true,
threadSettlement: true,
threadAutoSettlement: true,
threadRestartContinuation: true,
Expand Down
225 changes: 224 additions & 1 deletion apps/server/src/git/GitWorkflowService.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { assert, describe, expect, it, vi } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import { VcsRepositoryDetectionError } from "@t3tools/contracts";

Expand All @@ -9,16 +11,19 @@ import * as GitWorkflowService from "./GitWorkflowService.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";

const testCommitHash = "0123456789abcdef0123456789abcdef01234567";

function makeLayer(input: {
readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"];
readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;
}) {
return GitWorkflowService.layer.pipe(
Layer.provide(
Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
detect: input.detect,
}),
),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(input.git ?? {})),
Layer.provide(Layer.mock(GitManager.GitManager)({})),
);
}
Expand Down Expand Up @@ -135,6 +140,224 @@ describe("GitWorkflowService", () => {
),
);

it.effect("returns non-repository History results when no VCS repository is detected", () => {
const detect = vi.fn(() => Effect.succeed(null));
const listHistoryRefs = vi.fn(() =>
Effect.succeed({
refs: [],
currentRef: null,
isRepo: false,
hasPrimaryRemote: false,
nextCursor: null,
isComplete: true,
}),
);
const getHistory = vi.fn(() =>
Effect.succeed({
commits: [],
isRepo: false,
nextCursor: null,
hasMore: false,
capped: false,
}),
);
const getCommitDetails = vi.fn(() => Effect.succeed({ commit: null, isRepo: false }));
const listCommitFiles = vi.fn(() =>
Effect.succeed({ files: [], isRepo: false, nextCursor: null, hasMore: false, capped: false }),
);
const getCommitDiff = vi.fn(() =>
Effect.succeed({ diff: "", truncated: false, isRepo: false }),
);

return Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
const refs = yield* workflow.listHistoryRefs({ cwd: "/not-a-repo" });
const history = yield* workflow.getHistory({ cwd: "/not-a-repo" });
const details = yield* workflow.getCommitDetails({
cwd: "/not-a-repo",
hash: testCommitHash,
});
const files = yield* workflow.listCommitFiles({
cwd: "/not-a-repo",
hash: testCommitHash,
});
const diff = yield* workflow.getCommitDiff({
cwd: "/not-a-repo",
hash: testCommitHash,
});

assert.deepStrictEqual(refs, {
refs: [],
currentRef: null,
isRepo: false,
hasPrimaryRemote: false,
nextCursor: null,
isComplete: true,
});
assert.deepStrictEqual(history, {
commits: [],
isRepo: false,
nextCursor: null,
hasMore: false,
capped: false,
});
assert.deepStrictEqual(details, { commit: null, isRepo: false });
assert.deepStrictEqual(files, {
files: [],
isRepo: false,
nextCursor: null,
hasMore: false,
capped: false,
});
assert.deepStrictEqual(diff, { diff: "", truncated: false, isRepo: false });
assert.equal(detect.mock.calls.length, 5);
assert.equal(listHistoryRefs.mock.calls.length, 0);
assert.equal(getHistory.mock.calls.length, 0);
assert.equal(getCommitDetails.mock.calls.length, 0);
assert.equal(listCommitFiles.mock.calls.length, 0);
assert.equal(getCommitDiff.mock.calls.length, 0);
}).pipe(
Effect.provide(
makeLayer({
detect,
git: { listHistoryRefs, getHistory, getCommitDetails, listCommitFiles, getCommitDiff },
}),
),
);
});

it.effect("delegates each History operation to GitVcsDriver once for Git repositories", () => {
const detect = vi.fn(() =>
Effect.succeed({
kind: "git" as const,
repository: {
kind: "git" as const,
rootPath: "/repo",
metadataPath: null,
freshness: {
source: "live-local" as const,
observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
expiresAt: Option.none(),
},
},
driver: {} as never,
} satisfies VcsDriverRegistry.VcsDriverHandle),
);
const listHistoryRefs = vi.fn(() => Effect.die("unused"));
const getHistory = vi.fn(() => Effect.die("unused"));
const getCommitDetails = vi.fn(() => Effect.die("unused"));
const listCommitFiles = vi.fn(() => Effect.die("unused"));
const getCommitDiff = vi.fn(() => Effect.die("unused"));

return Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
const historyInput = { cwd: "/repo" };
const commitInput = {
cwd: "/repo",
hash: testCommitHash,
};

yield* workflow.listHistoryRefs(historyInput).pipe(Effect.exit);
yield* workflow.getHistory(historyInput).pipe(Effect.exit);
yield* workflow.getCommitDetails(commitInput).pipe(Effect.exit);
yield* workflow.listCommitFiles(commitInput).pipe(Effect.exit);
yield* workflow.getCommitDiff(commitInput).pipe(Effect.exit);

assert.deepStrictEqual(listHistoryRefs.mock.calls, [[historyInput]]);
assert.deepStrictEqual(getHistory.mock.calls, [[historyInput]]);
assert.deepStrictEqual(getCommitDetails.mock.calls, [[commitInput]]);
assert.deepStrictEqual(listCommitFiles.mock.calls, [[commitInput]]);
assert.deepStrictEqual(getCommitDiff.mock.calls, [[commitInput]]);
assert.equal(detect.mock.calls.length, 5);
}).pipe(
Effect.provide(
makeLayer({
detect,
git: { listHistoryRefs, getHistory, getCommitDetails, listCommitFiles, getCommitDiff },
}),
),
);
});

it.effect(
"returns non-repository History results for an explicitly configured jj repository",
() => {
const detect = vi.fn(() =>
Effect.succeed({
kind: "jj" as const,
repository: {
kind: "jj" as const,
rootPath: "/jj-repo",
metadataPath: "/jj-repo/.jj",
freshness: {
source: "live-local" as const,
observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
expiresAt: Option.none(),
},
},
driver: {} as never,
} satisfies VcsDriverRegistry.VcsDriverHandle),
);
const listHistoryRefs = vi.fn(() => Effect.die("must not call GitVcsDriver"));
const getHistory = vi.fn(() => Effect.die("must not call GitVcsDriver"));
const getCommitDetails = vi.fn(() => Effect.die("must not call GitVcsDriver"));
const listCommitFiles = vi.fn(() => Effect.die("must not call GitVcsDriver"));
const getCommitDiff = vi.fn(() => Effect.die("must not call GitVcsDriver"));

return Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
const historyInput = { cwd: "/jj-repo" };
const commitInput = { cwd: "/jj-repo", hash: testCommitHash };

assert.deepStrictEqual(yield* workflow.listHistoryRefs(historyInput), {
refs: [],
currentRef: null,
isRepo: false,
hasPrimaryRemote: false,
nextCursor: null,
isComplete: true,
});
assert.deepStrictEqual(yield* workflow.getHistory(historyInput), {
commits: [],
isRepo: false,
nextCursor: null,
hasMore: false,
capped: false,
});
assert.deepStrictEqual(yield* workflow.getCommitDetails(commitInput), {
commit: null,
isRepo: false,
});
assert.deepStrictEqual(yield* workflow.listCommitFiles(commitInput), {
files: [],
isRepo: false,
nextCursor: null,
hasMore: false,
capped: false,
});
assert.deepStrictEqual(yield* workflow.getCommitDiff(commitInput), {
diff: "",
truncated: false,
isRepo: false,
});

assert.equal(detect.mock.calls.length, 5);
assert.equal(listHistoryRefs.mock.calls.length, 0);
assert.equal(getHistory.mock.calls.length, 0);
assert.equal(getCommitDetails.mock.calls.length, 0);
assert.equal(listCommitFiles.mock.calls.length, 0);
assert.equal(getCommitDiff.mock.calls.length, 0);
}).pipe(
Effect.provide(
makeLayer({
detect,
git: { listHistoryRefs, getHistory, getCommitDetails, listCommitFiles, getCommitDiff },
}),
),
);
},
);

it.effect("structures workflow detection failures without exposing upstream details", () => {
const cause = new VcsRepositoryDetectionError({
operation: "VcsDriverRegistry.detect",
Expand Down
Loading
Loading