Skip to content
Open
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
33 changes: 33 additions & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import * as Layer from "effect/Layer";
import {
GitManagerError,
GitCommandError,
type GitDivergedError,
type VcsFetchResult,
type VcsPushResult,
type VcsSyncInput,
type VcsSyncResult,
type VcsSwitchRefInput,
type VcsSwitchRefResult,
type VcsCreateRefInput,
Expand Down Expand Up @@ -46,6 +51,12 @@ export interface GitWorkflowServiceShape {
readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
readonly fetchCurrentBranch: (cwd: string) => Effect.Effect<VcsFetchResult, GitCommandError>;
readonly pushCurrentBranch: (cwd: string) => Effect.Effect<VcsPushResult, GitCommandError>;
readonly syncCurrentBranch: (
cwd: string,
options?: { readonly mode?: VcsSyncInput["mode"] },
) => Effect.Effect<VcsSyncResult, GitCommandError | GitDivergedError>;
readonly runStackedAction: (
input: GitRunStackedActionInput,
options?: GitRunStackedActionOptions,
Expand Down Expand Up @@ -272,6 +283,28 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {
ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe(
Effect.andThen(git.pullCurrentBranch(cwd)),
),
fetchCurrentBranch: (cwd) =>
ensureGitCommand("GitWorkflowService.fetchCurrentBranch", cwd).pipe(
Effect.andThen(git.fetchCurrentBranch(cwd)),
),
pushCurrentBranch: (cwd) =>
ensureGitCommand("GitWorkflowService.pushCurrentBranch", cwd).pipe(
// Reuse the exact driver call the stacked-action push path uses so the
// standalone push and the bundled push share upstream-setting logic.
Effect.andThen(git.pushCurrentBranch(cwd, null)),
Effect.map(
(result): VcsPushResult => ({
status: result.status,
refName: result.branch,
upstreamRef: result.upstreamBranch ?? null,
setUpstream: result.setUpstream ?? false,
}),
),
),
syncCurrentBranch: (cwd, options) =>
ensureGitCommand("GitWorkflowService.syncCurrentBranch", cwd).pipe(
Effect.andThen(git.syncCurrentBranch(cwd, options)),
),
runStackedAction: (input, options) =>
ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe(
Effect.andThen(gitManager.runStackedAction(input, options)),
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/rpcRequiredScope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { WsRpcGroup } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { RPC_REQUIRED_SCOPE } from "./rpcRequiredScope.ts";

// The RPC dispatch layer in ws.ts throws at runtime when a served method has no
// entry in RPC_REQUIRED_SCOPE. These tests turn that latent runtime failure into
// a test failure: the map must match the set of methods WsRpcGroup actually serves.
describe("RPC_REQUIRED_SCOPE", () => {
const servedMethods = [...WsRpcGroup.requests.keys()];

it("declares an authorization scope for every served WsRpcGroup method", () => {
const missing = servedMethods.filter((method) => !RPC_REQUIRED_SCOPE.has(method));
expect(missing).toEqual([]);
});

it("does not declare scopes for methods that are not served", () => {
const served = new Set(servedMethods);
const stale = [...RPC_REQUIRED_SCOPE.keys()].filter((method) => !served.has(method));
expect(stale).toEqual([]);
});
});
79 changes: 79 additions & 0 deletions apps/server/src/rpcRequiredScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthReviewWriteScope,
AuthRelayWriteScope,
AuthTerminalOperateScope,
AuthAccessReadScope,
type AuthEnvironmentScope,
ORCHESTRATION_WS_METHODS,
WS_METHODS,
} from "@t3tools/contracts";

/**
* Authorization scope required to invoke each WebSocket RPC method.
*
* Every method served by `WsRpcGroup` must have an entry here — the RPC dispatch
* layer in ws.ts throws at runtime ("RPC method X has no declared authorization
* scope.") when a method is missing. `rpcRequiredScope.test.ts` asserts this map
* stays complete against `WsRpcGroup.requests`, so a newly added RPC fails in
* tests rather than on a live call.
*/
export const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
[ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope],
[ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope],
[ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope],
[ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope],
[ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope],
[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope],
[ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope],
[WS_METHODS.serverGetConfig, AuthOrchestrationReadScope],
[WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope],
[WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope],
[WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope],
[WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope],
[WS_METHODS.serverGetSettings, AuthOrchestrationReadScope],
[WS_METHODS.serverUpdateSettings, AuthOrchestrationOperateScope],
[WS_METHODS.serverDiscoverSourceControl, AuthOrchestrationReadScope],
[WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope],
[WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope],
[WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope],
[WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope],
[WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope],
[WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope],
[WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope],
[WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope],
[WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope],
[WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope],
[WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope],
[WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope],
[WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],
[WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],
[WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],
[WS_METHODS.vcsPull, AuthOrchestrationOperateScope],
[WS_METHODS.vcsFetch, AuthOrchestrationOperateScope],
[WS_METHODS.vcsPush, AuthOrchestrationOperateScope],
[WS_METHODS.vcsSync, AuthOrchestrationOperateScope],
[WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope],
[WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope],
[WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope],
[WS_METHODS.vcsListRefs, AuthOrchestrationReadScope],
[WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope],
[WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope],
[WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope],
[WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope],
[WS_METHODS.vcsInit, AuthOrchestrationOperateScope],
[WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope],
[WS_METHODS.terminalOpen, AuthTerminalOperateScope],
[WS_METHODS.terminalAttach, AuthTerminalOperateScope],
[WS_METHODS.terminalWrite, AuthTerminalOperateScope],
[WS_METHODS.terminalResize, AuthTerminalOperateScope],
[WS_METHODS.terminalClear, AuthTerminalOperateScope],
[WS_METHODS.terminalRestart, AuthTerminalOperateScope],
[WS_METHODS.terminalClose, AuthTerminalOperateScope],
[WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope],
[WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope],
[WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope],
[WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope],
[WS_METHODS.subscribeAuthAccess, AuthAccessReadScope],
]);
9 changes: 9 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";

import {
GitCommandError,
GitDivergedError,
VcsProcessExitError,
type VcsSwitchRefInput,
type VcsSwitchRefResult,
Expand All @@ -23,7 +24,10 @@ import {
type VcsInitInput,
type VcsListRefsInput,
type VcsListRefsResult,
type VcsFetchResult,
type VcsPullResult,
type VcsSyncInput,
type VcsSyncResult,
type VcsRemoveWorktreeInput,
type VcsStatusInput,
type VcsStatusResult,
Expand Down Expand Up @@ -204,6 +208,11 @@ export interface GitVcsDriverShape {
) => Effect.Effect<string | null, GitCommandError>;
readonly listRefs: (input: VcsListRefsInput) => Effect.Effect<VcsListRefsResult, GitCommandError>;
readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
readonly fetchCurrentBranch: (cwd: string) => Effect.Effect<VcsFetchResult, GitCommandError>;
readonly syncCurrentBranch: (
cwd: string,
options?: { readonly mode?: VcsSyncInput["mode"] },
) => Effect.Effect<VcsSyncResult, GitCommandError | GitDivergedError>;
readonly createWorktree: (
input: VcsCreateWorktreeInput,
) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
Expand Down
160 changes: 160 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,4 +512,164 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
}),
);
});

describe("branch sync", () => {
// Helper: a repo on `main` tracking a bare `origin`, both at the initial commit.
const initRepoWithRemote = (cwd: string, remote: string) =>
Effect.gen(function* () {
yield* initRepoWithCommit(cwd);
yield* git(cwd, ["branch", "-M", "main"]);
yield* git(remote, ["init", "--bare"]);
yield* git(cwd, ["remote", "add", "origin", remote]);
yield* git(cwd, ["push", "-u", "origin", "main"]);
});

it.effect("pushes when the branch is ahead of its upstream", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const remote = yield* makeTmpDir("git-remote-");
yield* initRepoWithRemote(cwd, remote);
yield* writeTextFile(cwd, "ahead.txt", "ahead\n");
yield* git(cwd, ["add", "ahead.txt"]);
yield* git(cwd, ["commit", "-m", "ahead commit"]);

const result = yield* (yield* GitVcsDriver.GitVcsDriver).syncCurrentBranch(cwd);

assert.deepStrictEqual(result, {
refName: "main",
fetched: true,
pull: "skipped",
push: "pushed",
setUpstream: false,
});
assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "main"]), "ahead commit");
}),
);

it.effect("fast-forward pulls when the branch is behind its upstream", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const remote = yield* makeTmpDir("git-remote-");
yield* initRepoWithRemote(cwd, remote);
// Advance the upstream, then rewind the local branch so it is purely behind.
yield* writeTextFile(cwd, "second.txt", "second\n");
yield* git(cwd, ["add", "second.txt"]);
yield* git(cwd, ["commit", "-m", "second commit"]);
yield* git(cwd, ["push", "origin", "main"]);
yield* git(cwd, ["reset", "--hard", "HEAD~1"]);

const result = yield* (yield* GitVcsDriver.GitVcsDriver).syncCurrentBranch(cwd);

assert.deepStrictEqual(result, {
refName: "main",
fetched: true,
pull: "pulled",
push: "skipped",
setUpstream: false,
});
assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "second commit");
}),
);

it.effect("fails with GitDivergedError when history has diverged", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const remote = yield* makeTmpDir("git-remote-");
yield* initRepoWithRemote(cwd, remote);
// Upstream gains a commit the local branch never sees...
yield* writeTextFile(cwd, "upstream.txt", "upstream\n");
yield* git(cwd, ["add", "upstream.txt"]);
yield* git(cwd, ["commit", "-m", "upstream commit"]);
yield* git(cwd, ["push", "origin", "main"]);
yield* git(cwd, ["reset", "--hard", "HEAD~1"]);
// ...while the local branch grows its own commit.
yield* writeTextFile(cwd, "local.txt", "local\n");
yield* git(cwd, ["add", "local.txt"]);
yield* git(cwd, ["commit", "-m", "local commit"]);

const error = yield* (yield* GitVcsDriver.GitVcsDriver)
.syncCurrentBranch(cwd)
.pipe(Effect.flip);

assert.equal(error._tag, "GitDivergedError");
if (error._tag === "GitDivergedError") {
assert.equal(error.refName, "main");
assert.isAtLeast(error.aheadCount, 1);
assert.isAtLeast(error.behindCount, 1);
}
// The working tree must be left clean — no half-finished rebase/merge.
assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "local commit");
}),
);

it.effect("rebases and pushes when the diverged sync opts into rebase", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const remote = yield* makeTmpDir("git-remote-");
yield* initRepoWithRemote(cwd, remote);
yield* writeTextFile(cwd, "upstream.txt", "upstream\n");
yield* git(cwd, ["add", "upstream.txt"]);
yield* git(cwd, ["commit", "-m", "upstream commit"]);
yield* git(cwd, ["push", "origin", "main"]);
yield* git(cwd, ["reset", "--hard", "HEAD~1"]);
yield* writeTextFile(cwd, "local.txt", "local\n");
yield* git(cwd, ["add", "local.txt"]);
yield* git(cwd, ["commit", "-m", "local commit"]);

const result = yield* (yield* GitVcsDriver.GitVcsDriver).syncCurrentBranch(cwd, {
mode: "rebase",
});

assert.deepStrictEqual(result, {
refName: "main",
fetched: true,
pull: "rebased",
push: "pushed",
setUpstream: false,
});
// Local commit replayed on top of the upstream commit and pushed back.
assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "main"]), "local commit");
}),
);

it.effect("publishes a branch that has no upstream", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const remote = yield* makeTmpDir("git-remote-");
yield* initRepoWithRemote(cwd, remote);
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* driver.createRef({ cwd, refName: "feature/publish-sync" });
yield* driver.switchRef({ cwd, refName: "feature/publish-sync" });
yield* writeTextFile(cwd, "feature.txt", "feature\n");
yield* git(cwd, ["add", "feature.txt"]);
yield* git(cwd, ["commit", "-m", "feature commit"]);

const result = yield* driver.syncCurrentBranch(cwd);

assert.deepStrictEqual(result, {
refName: "feature/publish-sync",
fetched: true,
pull: "skipped",
push: "pushed",
setUpstream: true,
});
assert.equal(
yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]),
"origin/feature/publish-sync",
);
}),
);

it.effect("reports the branch and upstream when fetching", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const remote = yield* makeTmpDir("git-remote-");
yield* initRepoWithRemote(cwd, remote);

const result = yield* (yield* GitVcsDriver.GitVcsDriver).fetchCurrentBranch(cwd);

assert.deepStrictEqual(result, { refName: "main", hasUpstream: true });
}),
);
});
});
Loading
Loading