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
13 changes: 12 additions & 1 deletion apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import { detachStackFrame } from "./detachStackFrame.ts";
import * as ServerSettings from "../serverSettings.ts";
import type { GitManagerServiceError } from "@t3tools/contracts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import { readCheckoutDetails } from "./checkoutDetails.ts";
import { isNonRepositoryGitStderr } from "../vcs/GitVcsDriverCore.ts";
import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
Expand Down Expand Up @@ -2465,7 +2466,17 @@ export const make = Effect.gen(function* () {
const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")(
function* (input) {
const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
return yield* Cache.get(localStatusResultCache, cacheKey);
const status = yield* Cache.get(localStatusResultCache, cacheKey);
if (!input.includeCheckoutDetails || !status.isRepo) return status;
const checkoutDetails = yield* readCheckoutDetails(cacheKey).pipe(
Effect.provideService(GitVcsDriver.GitVcsDriver, gitCore),
Effect.catch((error) =>
Effect.logWarning("GitManager.localStatus checkout details failed", {
errorTag: error._tag,
}).pipe(Effect.as(undefined)),
),
);
return { ...status, ...(checkoutDetails ? { checkoutDetails } : {}) };
},
);
const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")(
Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/git/checkoutDetails.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as ServerConfig from "../config.ts";
import { readCheckoutDetails } from "./checkoutDetails.ts";

it.live("reports the checked out revision, HEAD update time and exact origin/main divergence", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const cwd = yield* fs.makeTempDirectoryScoped();
const git = yield* GitVcsDriver.GitVcsDriver;
const run = (args: readonly string[]) =>
git.execute({ cwd, operation: "checkout-details-test", args });
yield* run(["init", "--initial-branch=main"]);
yield* run(["config", "user.name", "Test"]);
yield* run(["config", "user.email", "test@example.com"]);
yield* run(["commit", "--allow-empty", "-m", "first"]);
const first = (yield* run(["rev-parse", "HEAD"])).stdout.trim();
assert.isNull((yield* readCheckoutDetails(cwd))?.behindOriginMain);
yield* run(["commit", "--allow-empty", "-m", "second"]);
yield* run(["update-ref", "refs/remotes/origin/main", "HEAD"]);
yield* run(["checkout", "--detach", first]);
const details = yield* readCheckoutDetails(cwd);
assert.strictEqual(details?.commit, first);
assert.strictEqual(details?.behindOriginMain, 1);
assert.isTrue(Number.isFinite(Date.parse(details?.committedAt ?? "")));
assert.isTrue(Number.isFinite(Date.parse(details?.headUpdatedAt ?? "")));
yield* run(["checkout", "main"]);
assert.strictEqual((yield* readCheckoutDetails(cwd))?.behindOriginMain, 0);
}).pipe(
Effect.scoped,
Effect.provide(
GitVcsDriver.layer.pipe(
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), { prefix: "checkout-details-test-" }),
),
Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
),
),
),
);
27 changes: 27 additions & 0 deletions apps/server/src/git/checkoutDetails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as Effect from "effect/Effect";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";

export const readCheckoutDetails = Effect.fn("GitManager.readCheckoutDetails")(function* (
cwd: string,
) {
const git = yield* GitVcsDriver.GitVcsDriver;
const read = (args: readonly string[]) =>
git.execute({ operation: "GitManager.readCheckoutDetails", cwd, args, allowNonZeroExit: true });
const head = yield* read(["log", "-1", "--format=%H%n%cI", "HEAD", "--"]);
const [commit = "", committedAt = ""] = head.stdout.trim().split("\n");
if (head.exitCode !== 0 || !commit) return undefined;
const reflog = yield* read(["reflog", "-1", "--format=%gD", "--date=iso-strict", "HEAD"]);
const origin = yield* read(["rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"]);
const originMainCommit = origin.exitCode === 0 ? origin.stdout.trim() : null;
const behind = originMainCommit
? yield* read(["rev-list", "--count", `HEAD..${originMainCommit}`, "--"])
: null;
const count = behind?.exitCode === 0 ? Number(behind.stdout.trim()) : NaN;
return {
commit,
committedAt,
headUpdatedAt: /\{([^}]+)\}/.exec(reflog.stdout)?.[1] ?? null,
originMainCommit,
behindOriginMain: Number.isSafeInteger(count) && count >= 0 ? count : null,
};
});
62 changes: 60 additions & 2 deletions apps/server/src/vcs/VcsStatusBroadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function layerTestFor(state: {
automaticFetchInterval?: Effect.Effect<Duration.Duration>;
beforeRemoteStatus?: Effect.Effect<void>;
beforeLocalStatus?: Effect.Effect<void>;
localStatusIncludeCheckoutDetailsValues?: Array<boolean | undefined>;
beforeCurrentBranch?: Effect.Effect<void>;
beforeInvalidate?: Effect.Effect<void>;
branchRead?: () => Effect.Effect<string | null>;
Expand All @@ -106,9 +107,10 @@ function layerTestFor(state: {
),
Layer.provide(
Layer.mock(GitWorkflowService.GitWorkflowService)({
localStatus: () =>
localStatus: (input) =>
Effect.suspend(() => {
state.localStatusCalls += 1;
state.localStatusIncludeCheckoutDetailsValues?.push(input.includeCheckoutDetails);
if (state.failLocalStatus) {
return (state.onLocalStatusFailure ?? Effect.void).pipe(
Effect.andThen(
Expand All @@ -122,8 +124,10 @@ function layerTestFor(state: {
),
);
}
const { checkoutDetails: _checkoutDetails, ...ordinaryStatus } =
state.currentLocalStatus;
return (state.beforeLocalStatus ?? Effect.void).pipe(
Effect.as(state.currentLocalStatus),
Effect.as(input.includeCheckoutDetails ? state.currentLocalStatus : ordinaryStatus),
);
}),
repositoryRoot: () => Effect.sync(() => (state.currentLocalStatus.isRepo ? "/repo" : null)),
Expand Down Expand Up @@ -201,6 +205,60 @@ function layerBackgroundPolicy(shouldRunScopeWork: (scope: BackgroundScope) => b
}

describe("VcsStatusBroadcaster", () => {
it.effect("publishes checkout metadata changes to detail subscribers", () => {
const firstDetails = {
commit: "1111111111111111111111111111111111111111",
committedAt: "2026-11-04T10:00:00.000Z",
headUpdatedAt: "2026-11-04T10:00:00.000Z",
originMainCommit: null,
behindOriginMain: null,
};
const state: Parameters<typeof layerTestFor>[0] = {
currentLocalStatus: { ...baseLocalStatus, checkoutDetails: firstDetails },
currentRemoteStatus: null,
localStatusCalls: 0,
remoteStatusCalls: 0,
localInvalidationCalls: 0,
remoteInvalidationCalls: 0,
localStatusIncludeCheckoutDetailsValues: [],
};

return Effect.gen(function* () {
const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
yield* broadcaster.refreshLocalStatus("/repo");
const subscribed = yield* Deferred.make<void>();
const published = yield* Deferred.make<VcsStatusLocalResult>();
yield* broadcaster
.streamStatus({ cwd: "/repo", includeRemote: false, includeCheckoutDetails: true })
.pipe(
Stream.runForEach((event) => {
if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined);
if (event._tag === "localUpdated") return Deferred.succeed(published, event.local);
return Effect.void;
}),
Effect.forkScoped,
);
yield* Deferred.await(subscribed);
state.localStatusIncludeCheckoutDetailsValues = [];
state.currentLocalStatus = {
...baseLocalStatus,
checkoutDetails: {
...firstDetails,
commit: "2222222222222222222222222222222222222222",
committedAt: "2026-11-04T11:00:00.000Z",
},
};
yield* broadcaster.refreshLocalStatus("/repo");

assert.isTrue(state.localStatusIncludeCheckoutDetailsValues?.at(-1));
assert.deepStrictEqual((yield* Deferred.await(published)).checkoutDetails, {
...firstDetails,
commit: "2222222222222222222222222222222222222222",
committedAt: "2026-11-04T11:00:00.000Z",
});
}).pipe(Effect.provide(layerTestFor(state)), Effect.scoped);
});

it.effect("refreshes queued behind a running one share the next git status read", () => {
const state: Parameters<typeof layerTestFor>[0] = {
currentLocalStatus: baseLocalStatus,
Expand Down
97 changes: 65 additions & 32 deletions apps/server/src/vcs/VcsStatusBroadcaster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,31 @@ export const make = Effect.gen(function* () {
const withRemoteWriteLock = <A, E, R>(cwd: string, effect: Effect.Effect<A, E, R>) =>
remoteWriteLocks.withLock(cwd, effect);
const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>());
const checkoutDetailDemandRef = yield* Ref.make(new Map<string, number>());

const retainCheckoutDetails = (cwd: string) =>
Ref.update(checkoutDetailDemandRef, (current) =>
new Map(current).set(cwd, (current.get(cwd) ?? 0) + 1),
);
const releaseCheckoutDetails = (cwd: string) =>
Ref.update(checkoutDetailDemandRef, (current) => {
const next = new Map(current);
const count = next.get(cwd) ?? 0;
if (count <= 1) next.delete(cwd);
else next.set(cwd, count - 1);
return next;
});
// Published local status must keep checkout details while a stream asks for them.
const readPublishedLocalStatus = (cwd: string) =>
Ref.get(checkoutDetailDemandRef).pipe(
Effect.flatMap((demand) =>
workflow.localStatus({
cwd,
...(demand.has(cwd) ? { includeCheckoutDetails: true } : {}),
}),
),
);

const headWatchersRef = yield* SynchronizedRef.make(new Map<string, ActiveHeadWatcher>());

const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* (
Expand Down Expand Up @@ -378,7 +403,7 @@ export const make = Effect.gen(function* () {
const branch = yield* workflow.currentBranch(cwd);
if (branch === cached.local.value.refName) return cached.local.value;
yield* workflow.invalidateLocalStatus(cwd);
return yield* updateCachedLocalStatus(cwd, yield* workflow.localStatus({ cwd }), {
return yield* updateCachedLocalStatus(cwd, yield* readPublishedLocalStatus(cwd), {
publish: true,
});
}
Expand Down Expand Up @@ -439,7 +464,7 @@ export const make = Effect.gen(function* () {
if (shared !== undefined && shared.startedAt >= requestedAt) return shared.local;
const startedAt = localRefreshRequests.get(cwd) ?? requestedAt;
yield* workflow.invalidateLocalStatus(cwd);
const local = yield* workflow.localStatus({ cwd });
const local = yield* readPublishedLocalStatus(cwd);
localRefreshResults.set(cwd, { startedAt, local });
return yield* updateCachedLocalStatus(cwd, local, { publish: true });
}),
Expand Down Expand Up @@ -790,7 +815,7 @@ export const make = Effect.gen(function* () {
return previous ? new Map(cache).set(cwd, { ...previous, local: null }) : cache;
});
if (detailedSubscribers.has(cwd)) {
const local = yield* workflow.localStatus({ cwd });
const local = yield* readPublishedLocalStatus(cwd);
yield* updateCachedLocalStatus(cwd, local, { publish: true });
return {
refName: local.refName,
Expand Down Expand Up @@ -917,35 +942,43 @@ export const make = Effect.gen(function* () {
else detailedSubscribers.set(cwd, remaining);
}),
);
const subscription = yield* PubSub.subscribe(changesPubSub);
// Watch before reading so a checkout in between still publishes.
yield* Effect.acquireRelease(retainHeadWatcher(cwd), () => releaseHeadWatcher(cwd));
const initialLocal = yield* getOrLoadLocalStatus(cwd);
const cachedStatus = yield* getCachedStatus(cwd);
const initialRemote = cachedStatus?.remote?.value ?? null;
if (input.includeRemote !== false) {
yield* retainRemotePoller(
cwd,
input.cwd,
cachedStatus?.remote === null || cachedStatus?.remote === undefined,
);
}
const release =
input.includeRemote === false
? Effect.void
: releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid);

return Stream.concat(
Stream.make({
_tag: "snapshot" as const,
local: initialLocal,
remote: initialRemote,
}),
Stream.fromSubscription(subscription).pipe(
Stream.filter((event) => event.cwd === cwd),
Stream.map((event) => event.event),
),
).pipe(Stream.ensuring(release));
if (input.includeCheckoutDetails) yield* retainCheckoutDetails(cwd);
const releaseCheckoutDetailDemand = input.includeCheckoutDetails
? releaseCheckoutDetails(cwd)
: Effect.void;
return yield* Effect.gen(function* () {
const subscription = yield* PubSub.subscribe(changesPubSub);
// Watch before reading so a checkout in between still publishes.
yield* Effect.acquireRelease(retainHeadWatcher(cwd), () => releaseHeadWatcher(cwd));
const initialLocal = input.includeCheckoutDetails
? yield* workflow.localStatus({ cwd, includeCheckoutDetails: true })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium vcs/VcsStatusBroadcaster.ts:954

A detailed stream can emit a snapshot with checkout details and then deliver an older queued localUpdated event without those details, removing the hover card’s version, dates, and comparison until another refresh. The in-flight refresh may have sampled checkoutDetailDemandRef before this subscriber retained demand, and subscribing before reading the snapshot queues its event; serialize the detailed initial read with localRefreshLocks and ensure queued pre-snapshot updates cannot replace the detailed state.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/vcs/VcsStatusBroadcaster.ts around line 954:

A detailed stream can emit a snapshot with checkout details and then deliver an older queued `localUpdated` event without those details, removing the hover card’s version, dates, and comparison until another refresh. The in-flight refresh may have sampled `checkoutDetailDemandRef` before this subscriber retained demand, and subscribing before reading the snapshot queues its event; serialize the detailed initial read with `localRefreshLocks` and ensure queued pre-snapshot updates cannot replace the detailed state.

: yield* getOrLoadLocalStatus(cwd);
const cachedStatus = yield* getCachedStatus(cwd);
const initialRemote = cachedStatus?.remote?.value ?? null;
if (input.includeRemote !== false) {
yield* retainRemotePoller(
cwd,
input.cwd,
cachedStatus?.remote === null || cachedStatus?.remote === undefined,
);
}
const release =
input.includeRemote === false
? Effect.void
: releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid);

return Stream.concat(
Stream.make({
_tag: "snapshot" as const,
local: initialLocal,
remote: initialRemote,
}),
Stream.fromSubscription(subscription).pipe(
Stream.filter((event) => event.cwd === cwd),
Stream.map((event) => event.event),
),
).pipe(Stream.ensuring(Effect.all([release, releaseCheckoutDetailDemand])));
}).pipe(Effect.onError(() => releaseCheckoutDetailDemand));
}),
);

Expand Down
Loading
Loading