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
41 changes: 32 additions & 9 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,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 @@ -129,6 +134,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 @@ -1125,9 +1131,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 @@ -1146,13 +1155,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 @@ -2409,9 +2427,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 remoteStatus: (
Expand Down Expand Up @@ -325,11 +326,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
115 changes: 113 additions & 2 deletions apps/server/src/vcs/VcsStatusBroadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Queue from "effect/Queue";
import * as Scope from "effect/Scope";
import * as Scheduler from "effect/Scheduler";
import * as Stream from "effect/Stream";
Expand Down Expand Up @@ -72,6 +73,7 @@ function layerTestFor(state: {
localInvalidationCalls: number;
remoteInvalidationCalls: number;
remoteStatusRefreshUpstreamValues?: Array<boolean | undefined>;
localStatusBranchChanges?: Array<boolean>;
remoteStatusError?: GitManagerError;
backgroundWorkEnabled?: boolean;
automaticFetchInterval?: Effect.Effect<Duration.Duration>;
Expand All @@ -95,11 +97,15 @@ 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;
return (state.beforeLocalStatus ?? Effect.void).pipe(
Effect.as(state.currentLocalStatus),
Effect.as(includeBranchChanges ? state.currentLocalStatus : withoutBranchChanges),
);
}),
currentBranch: () => Effect.sync(() => state.currentLocalStatus.refName),
Expand Down Expand Up @@ -717,6 +723,111 @@ describe("VcsStatusBroadcaster", () => {
}).pipe(Effect.provide(layerTestFor(state)), Effect.scoped);
});

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 sidebarEvents = yield* Queue.unbounded<VcsStatusStreamEvent>();
yield* broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }).pipe(
Stream.runForEach((event) => Queue.offer(sidebarEvents, event)),
Effect.forkScoped,
);
const sidebarSnapshot = yield* Queue.take(sidebarEvents);
assert.strictEqual(sidebarSnapshot._tag, "snapshot");
if (sidebarSnapshot._tag === "snapshot") {
assert.isUndefined(sidebarSnapshot.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 sidebarUpdate = yield* Queue.take(sidebarEvents);
assert.strictEqual(sidebarUpdate._tag, "localUpdated");
if (sidebarUpdate._tag === "localUpdated") {
assert.deepStrictEqual(sidebarUpdate.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: [],
};

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),
);
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);
const secondEvents = yield* subscribeHeader(yield* Scope.make());
const snapshot = yield* Queue.take(secondEvents);
assert.strictEqual(snapshot._tag, "snapshot");
yield* Deferred.succeed(finishRead, undefined);

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
Loading
Loading