Skip to content
41 changes: 32 additions & 9 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ export interface GitRunStackedActionOptions {
readonly progressReporter?: GitActionProgressReporter;
}

export interface GitLocalStatusOptions {
/** Defaults to true. The branch-wide totals cost about half of a local status read. */
readonly includeBranchChanges?: boolean;
}

export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOptions {
readonly refreshMissingPullRequest?: boolean;
}
Expand Down Expand Up @@ -132,6 +137,7 @@ export class GitManager extends Context.Service<
) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
readonly localStatus: (
input: VcsStatusInput,
options?: GitLocalStatusOptions,
) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
readonly remoteStatus: (
input: VcsStatusInput,
Expand Down Expand Up @@ -1202,9 +1208,12 @@ export const make = Effect.gen(function* () {
behindCount: 0,
aheadOfDefaultCount: 0,
};
const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
const readLocalStatus = Effect.fn("readLocalStatus")(function* (
cwd: string,
includeBranchChanges: boolean,
) {
const details = yield* gitCore
.statusDetailsLocal(cwd, { includeDivergence: false, includeBranchChanges: true })
.statusDetailsLocal(cwd, { includeDivergence: false, includeBranchChanges })
.pipe(
Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
);
Expand All @@ -1223,13 +1232,22 @@ export const make = Effect.gen(function* () {
...(details.branchChanges ? { branchChanges: details.branchChanges } : {}),
} satisfies VcsStatusLocalResult;
});
const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, {
capacity: STATUS_RESULT_CACHE_CAPACITY,
timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
});
const makeLocalStatusResultCache = (includeBranchChanges: boolean) =>
Cache.makeWith((cwd: string) => readLocalStatus(cwd, includeBranchChanges), {
capacity: STATUS_RESULT_CACHE_CAPACITY,
timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
});
// Separate entries, so a read without totals is never served to a caller that shows them.
const localStatusResultCache = yield* makeLocalStatusResultCache(true);
const localStatusWithoutBranchChangesCache = yield* makeLocalStatusResultCache(false);
const invalidateLocalStatusResultCache = (cwd: string) =>
normalizeStatusCacheKey(cwd).pipe(
Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)),
Effect.flatMap((cacheKey) =>
Effect.all([
Cache.invalidate(localStatusResultCache, cacheKey),
Cache.invalidate(localStatusWithoutBranchChangesCache, cacheKey),
]),
),
);
const prLookupEpochByCwd = new Map<string, number>();
const prLookupEpoch = (cwd: string) => prLookupEpochByCwd.get(cwd) ?? 0;
Expand Down Expand Up @@ -2554,9 +2572,14 @@ export const make = Effect.gen(function* () {
});

const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")(
function* (input) {
function* (input, options) {
const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
return yield* Cache.get(localStatusResultCache, cacheKey);
return yield* Cache.get(
options?.includeBranchChanges === false
? localStatusWithoutBranchChangesCache
: localStatusResultCache,
cacheKey,
);
},
);
const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")(
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class GitWorkflowService extends Context.Service<
) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
readonly localStatus: (
input: VcsStatusInput,
options?: GitManager.GitLocalStatusOptions,
) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
readonly currentBranch: (cwd: string) => Effect.Effect<string | null, GitManagerServiceError>;
readonly upstreamDivergence: (cwd: string) => Effect.Effect<
Expand Down Expand Up @@ -341,11 +342,11 @@ export const make = Effect.gen(function* () {
),
);
}),
localStatus: Effect.fn("GitWorkflowService.localStatus")(function (input) {
localStatus: Effect.fn("GitWorkflowService.localStatus")(function (input, options) {
return detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe(
Effect.flatMap((isGitRepository) =>
isGitRepository
? gitManager.localStatus(input)
? gitManager.localStatus(input, options)
: Effect.succeed(nonRepositoryLocalStatus()),
),
);
Expand Down
130 changes: 124 additions & 6 deletions apps/server/src/vcs/VcsStatusBroadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ function layerTestFor(state: {
localInvalidationCalls: number;
remoteInvalidationCalls: number;
remoteStatusRefreshUpstreamValues?: Array<boolean | undefined>;
localStatusBranchChanges?: Array<boolean>;
refSnapshot?: string | null;
remoteStatusError?: GitManagerError;
backgroundWorkEnabled?: boolean;
Expand Down Expand Up @@ -109,9 +110,13 @@ function layerTestFor(state: {
),
Layer.provide(
Layer.mock(GitWorkflowService.GitWorkflowService)({
localStatus: () =>
localStatus: (_input, options) =>
Effect.suspend(() => {
state.localStatusCalls += 1;
const includeBranchChanges = options?.includeBranchChanges !== false;
state.localStatusBranchChanges?.push(includeBranchChanges);
const { branchChanges: _branchChanges, ...withoutBranchChanges } =
state.currentLocalStatus;
if (state.failLocalStatus) {
return (state.onLocalStatusFailure ?? Effect.void).pipe(
Effect.andThen(
Expand All @@ -126,7 +131,7 @@ function layerTestFor(state: {
);
}
return (state.beforeLocalStatus ?? Effect.void).pipe(
Effect.as(state.currentLocalStatus),
Effect.as(includeBranchChanges ? state.currentLocalStatus : withoutBranchChanges),
);
}),
repositoryRoot: () => Effect.sync(() => (state.currentLocalStatus.isRepo ? "/repo" : null)),
Expand Down Expand Up @@ -1158,6 +1163,116 @@ describe("VcsStatusBroadcaster", () => {
}).pipe(Effect.provide(layerTestFor(state)));
});

it.effect("reads Changes totals only while a subscriber shows them", () => {
const branchChanges = { baseRef: "origin/main", insertions: 3, deletions: 1 };
const state: Parameters<typeof layerTestFor>[0] = {
currentLocalStatus: { ...baseLocalStatus, branchChanges },
currentRemoteStatus: baseRemoteStatus,
localStatusCalls: 0,
remoteStatusCalls: 0,
localInvalidationCalls: 0,
remoteInvalidationCalls: 0,
localStatusBranchChanges: [],
};

return Effect.gen(function* () {
const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
const paletteEvents = yield* Queue.unbounded<VcsStatusStreamEvent>();
yield* broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }).pipe(
Stream.runForEach((event) => Queue.offer(paletteEvents, event)),
Effect.forkScoped,
);
const paletteSnapshot = yield* Queue.take(paletteEvents);
assert.strictEqual(paletteSnapshot._tag, "snapshot");
if (paletteSnapshot._tag === "snapshot") {
assert.isUndefined(paletteSnapshot.local.branchChanges);
}
yield* broadcaster.refreshLocalStatus("/repo");
assert.deepStrictEqual(state.localStatusBranchChanges, [false, false]);

const headerScope = yield* Scope.make();
const headerEvents = yield* Queue.unbounded<VcsStatusStreamEvent>();
yield* broadcaster.streamStatus({ cwd: "/repo" }).pipe(
Stream.runForEach((event) => Queue.offer(headerEvents, event)),
Effect.forkIn(headerScope),
);
const headerSnapshot = yield* Queue.take(headerEvents);
assert.strictEqual(headerSnapshot._tag, "snapshot");
if (headerSnapshot._tag === "snapshot") {
assert.deepStrictEqual(headerSnapshot.local.branchChanges, branchChanges);
}
const paletteUpdate = yield* Queue.take(paletteEvents);
assert.strictEqual(paletteUpdate._tag, "localUpdated");
if (paletteUpdate._tag === "localUpdated") {
assert.deepStrictEqual(paletteUpdate.local.branchChanges, branchChanges);
}
// The header's first remote poll also rereads local status.
while ((yield* Queue.take(headerEvents))._tag !== "remoteUpdated") {}
const turnEnd = yield* broadcaster.refreshLocalStatus("/repo");
assert.deepStrictEqual(turnEnd.branchChanges, branchChanges);

yield* Scope.close(headerScope, Exit.void);
yield* broadcaster.refreshLocalStatus("/repo");
assert.deepStrictEqual(state.localStatusBranchChanges, [
false,
false,
true,
true,
true,
false,
]);
}).pipe(Effect.provide(layerTestFor(state)), Effect.scoped);
});

it.effect("a light refresh that a header joins mid-read publishes Changes totals", () => {
const branchChanges = { baseRef: "origin/main", insertions: 3, deletions: 1 };
const state: Parameters<typeof layerTestFor>[0] = {
currentLocalStatus: { ...baseLocalStatus, branchChanges },
currentRemoteStatus: baseRemoteStatus,
localStatusCalls: 0,
remoteStatusCalls: 0,
localInvalidationCalls: 0,
remoteInvalidationCalls: 0,
localStatusBranchChanges: [],
fileSystem: FileSystem.makeNoop({ realPath: (cwd) => Effect.succeed(cwd) }),
};

return Effect.gen(function* () {
const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
const subscribeHeader = Effect.fn("subscribeHeader")(function* (scope: Scope.Scope) {
const events = yield* Queue.unbounded<VcsStatusStreamEvent>();
yield* broadcaster.streamStatus({ cwd: "/repo" }).pipe(
Stream.runForEach((event) => Queue.offer(events, event)),
Effect.forkIn(scope, { startImmediately: true }),
);
return events;
});
const firstHeader = yield* Scope.make();
const firstEvents = yield* subscribeHeader(firstHeader);
while ((yield* Queue.take(firstEvents))._tag !== "remoteUpdated") {}
yield* Scope.close(firstHeader, Exit.void);

const readStarted = yield* Deferred.make<void>();
const finishRead = yield* Deferred.make<void>();
state.beforeLocalStatus = Deferred.succeed(readStarted, undefined).pipe(
Effect.andThen(Deferred.await(finishRead)),
);
const turnEnd = yield* broadcaster.refreshLocalStatus("/repo").pipe(Effect.forkScoped);
yield* Deferred.await(readStarted);
// Starting immediately registers the header's demand before it waits behind the read.
const secondEvents = yield* subscribeHeader(yield* Scope.make());
yield* Deferred.succeed(finishRead, undefined);
const snapshot = yield* Queue.take(secondEvents);
assert.strictEqual(snapshot._tag, "snapshot");
if (snapshot._tag === "snapshot") {
assert.deepStrictEqual(snapshot.local.branchChanges, branchChanges);
}

assert.deepStrictEqual((yield* Fiber.join(turnEnd)).branchChanges, branchChanges);
assert.deepStrictEqual(state.localStatusBranchChanges?.slice(-2), [false, true]);
}).pipe(Effect.provide(layerTestFor(state)), Effect.scoped);
});

it.effect("an explicit refresh reads local totals after the fetch", () => {
return Effect.gen(function* () {
const fetchStarted = yield* Deferred.make<void>();
Expand Down Expand Up @@ -1233,7 +1348,7 @@ describe("VcsStatusBroadcaster", () => {
event._tag === "snapshot" ? Deferred.succeed(subscribed, undefined) : Effect.void,
),
Stream.filter((event) => event._tag !== "snapshot"),
Stream.take(3),
Stream.take(4),
Stream.runCollect,
Effect.forkScoped,
);
Expand All @@ -1254,7 +1369,8 @@ describe("VcsStatusBroadcaster", () => {
assert.deepStrictEqual(moved.branchChanges, totals(0).branchChanges);
assert.deepStrictEqual(
[...(yield* Fiber.join(events))].map((event) => event._tag),
["remoteUpdated", "remoteUpdated", "localUpdated"],
// The first refresh adds the totals this palette-style subscription skipped.
["localUpdated", "remoteUpdated", "remoteUpdated", "localUpdated"],
);

state.localStatusCalls = 0;
Expand Down Expand Up @@ -2097,8 +2213,10 @@ it.effect("serializes branch reads with detailed refresh publication", () => {
yield* Fiber.join(older);
yield* Fiber.join(newer);
state.branchRead = () => Effect.succeed("new");
const result = yield* b.getStatus({ cwd: "/repo" });
assert.equal(result.refName, "new");
const result = yield* b
.streamStatus({ cwd: "/repo", includeRemote: false })
.pipe(Stream.runHead, Effect.flatMap(Effect.fromOption));
assert.equal(result._tag === "snapshot" && result.local.refName, "new");
assert.equal(state.localStatusCalls, 1);
}).pipe(Effect.provide(layerTestFor(state)));
});
Expand Down
Loading
Loading