Skip to content

Commit e677e97

Browse files
fix(server): publish local git status before remote lookup (#21046)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent e34067e commit e677e97

4 files changed

Lines changed: 559 additions & 159 deletions

File tree

‎apps/server/src/git/GitManager.test.ts‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
2+
import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
13
import { SourceControlProviderKind } from "@t3tools/contracts";
24
// @effect-diagnostics nodeBuiltinImport:off
35
import * as NodeFS from "node:fs";
@@ -69,6 +71,10 @@ type FakeChangeRequest = Omit<ChangeRequest, "provider" | "state" | "updatedAt">
6971
};
7072

7173
interface FakeProviderScenario {
74+
prListGate?: {
75+
readonly started: Deferred.Deferred<void>;
76+
readonly release: Deferred.Deferred<void>;
77+
};
7278
/** The kind the fake host reports itself as. */
7379
kind?: string;
7480
/** Answers to successive change request listings, whatever their head selector. */
@@ -452,6 +458,13 @@ function createFakeSourceControlProvider(scenario: FakeProviderScenario = {}): {
452458
[]
453459
).map(toChangeRequest),
454460
),
461+
Effect.tap(() =>
462+
scenario.prListGate
463+
? Deferred.succeed(scenario.prListGate.started, undefined).pipe(
464+
Effect.andThen(Deferred.await(scenario.prListGate.release)),
465+
)
466+
: Effect.void,
467+
),
455468
),
456469
createChangeRequest: (input) =>
457470
record(input.cwd, {
@@ -723,6 +736,108 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe(
723736
);
724737

725738
it.layer(layerGitManagerTest)("GitManager", (it) => {
739+
it.effect(
740+
"publishes local Git changes during a held PR lookup without caching its old branch",
741+
() =>
742+
Effect.gen(function* () {
743+
const repoDir = yield* makeTempDir("t3code-refresh-status-gh-gate-");
744+
yield* initRepo(repoDir);
745+
yield* runGit(repoDir, ["checkout", "-b", "release"]);
746+
const remoteDir = yield* createBareRemote();
747+
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
748+
yield* runGit(repoDir, ["push", "-u", "origin", "release"]);
749+
yield* runGit(repoDir, ["tag", "release"]);
750+
const oldPr = {
751+
number: 613,
752+
title: "Release PR",
753+
url: "https://github.com/pingdotgg/codething-mvp/pull/613",
754+
baseRefName: "main",
755+
headRefName: "release",
756+
};
757+
const newPr = { ...oldPr, number: 614, headRefName: "heads/release" };
758+
const providerScenario: FakeProviderScenario = {
759+
prListSequence: [oldPr, oldPr, newPr].map((pr) => [pr]),
760+
};
761+
const { manager } = yield* makeManager({ providerScenario });
762+
const context = yield* Layer.build(
763+
VcsStatusBroadcaster.layer.pipe(
764+
Layer.provide(
765+
GitWorkflowService.layer.pipe(
766+
Layer.provide(Layer.succeed(GitManager.GitManager, manager)),
767+
Layer.provide(
768+
VcsDriverRegistry.layer.pipe(
769+
Layer.provide(VcsProjectConfig.layer),
770+
Layer.provide(VcsProcess.layer),
771+
),
772+
),
773+
),
774+
),
775+
Layer.provide(
776+
Layer.mock(BackgroundPolicy.BackgroundPolicy)({
777+
shouldRunScopeWork: () => Effect.succeed(true),
778+
}),
779+
),
780+
),
781+
);
782+
const broadcaster = Context.get(context, VcsStatusBroadcaster.VcsStatusBroadcaster);
783+
const initial = yield* broadcaster.getStatus({ cwd: repoDir });
784+
expect(initial.refName).toBe("release");
785+
expect(initial.pr?.number).toBe(613);
786+
787+
const subscribed = yield* Deferred.make<void>();
788+
const published = yield* Deferred.make<void>();
789+
yield* broadcaster.streamStatus({ cwd: repoDir, includeRemote: false }).pipe(
790+
Stream.runForEach((event) => {
791+
if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined);
792+
if (event._tag === "localUpdated" && event.local.hasWorkingTreeChanges) {
793+
return Deferred.succeed(published, undefined);
794+
}
795+
return Effect.void;
796+
}),
797+
Effect.forkScoped,
798+
);
799+
yield* Deferred.await(subscribed);
800+
const gate = {
801+
started: yield* Deferred.make<void>(),
802+
release: yield* Deferred.make<void>(),
803+
};
804+
providerScenario.prListGate = gate;
805+
const fs = yield* FileSystem.FileSystem;
806+
yield* fs.writeFileString(NodePath.join(repoDir, "README.md"), "changed before refresh\n");
807+
const completed = yield* Deferred.make<void>();
808+
const refresh = yield* broadcaster
809+
.refreshStatus(repoDir)
810+
.pipe(Effect.ensuring(Deferred.succeed(completed, undefined)), Effect.forkScoped);
811+
yield* Deferred.await(gate.started);
812+
const pending = Option.getOrThrow(
813+
yield* Stream.runHead(broadcaster.streamStatus({ cwd: repoDir, includeRemote: false })),
814+
);
815+
expect(pending._tag).toBe("snapshot");
816+
if (pending._tag === "snapshot") expect(pending.local.hasWorkingTreeChanges).toBe(true);
817+
yield* Deferred.await(published);
818+
expect(yield* Deferred.isDone(completed)).toBe(false);
819+
820+
yield* runGit(repoDir, ["checkout", "-b", "heads/release"]);
821+
yield* runGit(repoDir, ["push", "-u", "origin", "refs/heads/heads/release"]);
822+
yield* Deferred.succeed(gate.release, undefined);
823+
const refreshed = yield* Fiber.join(refresh);
824+
expect(refreshed.refName).toBe("heads/release");
825+
expect(refreshed.hasWorkingTreeChanges).toBe(true);
826+
expect(refreshed.pr).toBeNull();
827+
const snapshot = Option.getOrThrow(
828+
yield* Stream.runHead(broadcaster.streamStatus({ cwd: repoDir, includeRemote: false })),
829+
);
830+
expect(snapshot._tag).toBe("snapshot");
831+
if (snapshot._tag === "snapshot") expect(snapshot.remote).toBeNull();
832+
833+
delete providerScenario.prListGate;
834+
const recovered = yield* broadcaster.getStatus({ cwd: repoDir });
835+
expect(recovered.refName).toBe("heads/release");
836+
expect(recovered.pr?.number).toBe(614);
837+
expect((yield* broadcaster.getStatus({ cwd: repoDir })).pr?.number).toBe(614);
838+
}),
839+
);
840+
726841
it.effect("status reports the listed PR whose head is the branch", () =>
727842
Effect.gen(function* () {
728843
const repoDir = yield* makeTempDir("t3code-git-manager-");

‎apps/server/src/git/GitWorkflowService.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export class GitWorkflowService extends Context.Service<
5252
readonly localStatus: (
5353
input: VcsStatusInput,
5454
) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
55+
readonly currentBranch: (cwd: string) => Effect.Effect<string | null, GitManagerServiceError>;
5556
readonly remoteStatus: (
5657
input: VcsStatusInput,
5758
options?: GitManager.GitRemoteStatusOptions,
@@ -332,6 +333,32 @@ export const make = Effect.gen(function* () {
332333
),
333334
);
334335
}),
336+
currentBranch: Effect.fn("GitWorkflowService.currentBranch")(function* (cwd) {
337+
if (!(yield* detectGitRepositoryForStatus("GitWorkflowService.currentBranch", cwd))) {
338+
return null;
339+
}
340+
const result = yield* git.execute({
341+
operation: "GitWorkflowService.currentBranch",
342+
cwd,
343+
args: ["symbolic-ref", "--quiet", "HEAD"],
344+
allowNonZeroExit: true,
345+
timeoutMs: 5_000,
346+
});
347+
if (result.exitCode === 0) {
348+
return result.stdout.trim().replace(/^refs\/heads\//, "") || null;
349+
}
350+
if (result.exitCode === 1) return null;
351+
return yield* new GitCommandError({
352+
operation: "GitWorkflowService.currentBranch",
353+
command: "git symbolic-ref",
354+
cwd,
355+
argumentCount: 3,
356+
...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
357+
stdoutLength: result.stdout.length,
358+
stderrLength: result.stderr.length,
359+
detail: "Failed to read the checked-out branch.",
360+
});
361+
}),
335362
remoteStatus: Effect.fn("GitWorkflowService.remoteStatus")(function (input, options) {
336363
return detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe(
337364
Effect.flatMap((isGitRepository) =>

0 commit comments

Comments
 (0)