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
1 change: 1 addition & 0 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,7 @@ export const make = Effect.gen(function* () {
aheadCount: details.aheadCount,
behindCount: details.behindCount,
aheadOfDefaultCount: details.aheadOfDefaultCount,
...(details.remoteRefHash == null ? {} : { remoteRefHash: details.remoteRefHash }),
pr,
} satisfies VcsStatusRemoteResult;
});
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export interface GitRemoteStatusDetails {
isDefaultBranch: boolean;
branch: string | null;
upstreamRef: string | null;
remoteRefHash?: string | null;
hasUpstream: boolean;
aheadCount: number;
behindCount: number;
Expand Down
51 changes: 51 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,31 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
});

describe("review diff previews", () => {
it.effect("omits an untracked file after it is deleted", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
yield* initRepoWithCommit(cwd);
const driver = yield* GitVcsDriver.GitVcsDriver;
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const createdPath = pathService.join(cwd, "created.ts");
yield* writeTextFile(cwd, "created.ts", "export const created = true;\n");

const beforeDelete = yield* driver.getReviewDiffPreview({ cwd });
yield* fileSystem.remove(createdPath);
const afterDelete = yield* driver.getReviewDiffPreview({ cwd });

assert.include(
beforeDelete.sources.find((source) => source.kind === "working-tree")?.diff ?? "",
"created.ts",
);
assert.strictEqual(
afterDelete.sources.find((source) => source.kind === "working-tree")?.diff,
"",
);
}),
);

it.effect("drops an unterminated path from truncated NUL-separated git output", () =>
Effect.sync(() => {
const paths = splitNullSeparatedGitStdoutPaths({
Expand Down Expand Up @@ -326,6 +351,32 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
}),
);

it.effect("changes the remote ref hash when a remote-tracking ref moves", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
yield* initRepoWithCommit(cwd);
const initialCommit = yield* git(cwd, ["rev-parse", "HEAD"]);
yield* writeTextFile(cwd, "next.txt", "next\n");
yield* git(cwd, ["add", "next.txt"]);
yield* git(cwd, ["commit", "-m", "next commit"]);
const nextCommit = yield* git(cwd, ["rev-parse", "HEAD"]);
const driver = yield* GitVcsDriver.GitVcsDriver;

yield* git(cwd, ["update-ref", "refs/remotes/origin/base", initialCommit]);
const initialStatus = yield* driver.statusDetailsRemote(cwd, {
refreshUpstream: false,
});
yield* git(cwd, ["update-ref", "refs/remotes/origin/base", nextCommit]);
const nextStatus = yield* driver.statusDetailsRemote(cwd, {
refreshUpstream: false,
});

assert.isString(initialStatus.remoteRefHash);
assert.isString(nextStatus.remoteRefHash);
assert.notEqual(initialStatus.remoteRefHash, nextStatus.remoteRefHash);
}),
);

it.effect("can read cached remote divergence without fetching upstream", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitRemot
isDefaultBranch: false,
branch: null,
upstreamRef: null,
remoteRefHash: null,
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
Expand Down Expand Up @@ -1218,6 +1219,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*

const branchValue = branchResult.stdout.trim();
const branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null;
const remoteRefs = yield* runGitStdout("GitVcsDriver.statusDetailsRemote.remoteRefs", cwd, [
"for-each-ref",
"--sort=refname",
"--format=%(refname)%00%(objectname)",
"refs/remotes",
]);
const remoteRefHash = yield* crypto
.digest("SHA-256", new TextEncoder().encode(remoteRefs))
.pipe(
Effect.map(Encoding.encodeHex),
Effect.mapError(
(cause) =>
new GitCommandError({
operation: "GitVcsDriver.statusDetailsRemote.remoteRefHash",
command: "crypto.digest SHA-256",
cwd,
detail: "Failed to hash remote refs.",
cause,
}),
),
);
const upstream = yield* resolveCurrentUpstream(cwd);
const upstreamRef = upstream?.upstreamRef ?? null;
let aheadCount = 0;
Expand Down Expand Up @@ -1260,6 +1282,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
isDefaultBranch,
branch,
upstreamRef,
remoteRefHash,
hasUpstream: upstreamRef !== null,
aheadCount,
behindCount,
Expand Down
69 changes: 63 additions & 6 deletions apps/server/src/vcs/VcsStatusBroadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const baseRemoteStatus: VcsStatusRemoteResult = {
hasUpstream: true,
aheadCount: 0,
behindCount: 0,
remoteRefHash: "remote-ref-hash-1",
pr: null,
};

Expand Down Expand Up @@ -361,6 +362,7 @@ describe("VcsStatusBroadcaster", () => {
_tag: "snapshot",
local: baseLocalStatus,
remote: null,
localGeneration: 1,
} satisfies VcsStatusStreamEvent);
assert.deepStrictEqual(remoteUpdated, {
_tag: "remoteUpdated",
Expand All @@ -369,6 +371,46 @@ describe("VcsStatusBroadcaster", () => {
}).pipe(Effect.provide(makeTestLayer(state)));
});

it.effect("publishes a new local generation when the status summary is unchanged", () => {
const state = {
currentLocalStatus: baseLocalStatus,
currentRemoteStatus: baseRemoteStatus,
localStatusCalls: 0,
remoteStatusCalls: 0,
localInvalidationCalls: 0,
remoteInvalidationCalls: 0,
};

return Effect.gen(function* () {
const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>();
const localUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>();
yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => {
if (event._tag === "snapshot") {
return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore);
}
if (event._tag === "localUpdated") {
return Deferred.succeed(localUpdatedDeferred, event).pipe(Effect.ignore);
}
return Effect.void;
}).pipe(Effect.forkScoped);

const snapshot = yield* Deferred.await(snapshotDeferred);
yield* broadcaster.refreshLocalStatus("/repo");
const localUpdated = yield* Deferred.await(localUpdatedDeferred);

if (snapshot._tag !== "snapshot") {
return yield* Effect.die(`Expected snapshot, received ${snapshot._tag}`);
}
assert.strictEqual(snapshot.localGeneration, 1);
assert.deepStrictEqual(localUpdated, {
_tag: "localUpdated",
local: baseLocalStatus,
localGeneration: 2,
} satisfies VcsStatusStreamEvent);
}).pipe(Effect.provide(makeTestLayer(state)));
});

it.effect("loads remote status once when periodic refreshes are disabled", () => {
const state = {
currentLocalStatus: baseLocalStatus,
Expand Down Expand Up @@ -408,6 +450,7 @@ describe("VcsStatusBroadcaster", () => {
_tag: "snapshot",
local: baseLocalStatus,
remote: null,
localGeneration: 1,
} satisfies VcsStatusStreamEvent);
assert.deepStrictEqual(remoteUpdated, {
_tag: "remoteUpdated",
Expand Down Expand Up @@ -545,7 +588,7 @@ describe("VcsStatusBroadcaster", () => {
);
});

it.effect("delays automatic refresh when a cached remote snapshot is available", () => {
it.effect("publishes a changed remote ref hash after the scheduled refresh", () => {
const state = {
currentLocalStatus: baseLocalStatus,
currentRemoteStatus: baseRemoteStatus,
Expand All @@ -560,15 +603,21 @@ describe("VcsStatusBroadcaster", () => {
yield* broadcaster.getStatus({ cwd: "/repo" });
const scope = yield* Scope.make();
const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>();
const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>();
yield* Stream.runForEach(
broadcaster.streamStatus(
{ cwd: "/repo" },
{ automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)) },
),
(event) =>
event._tag === "snapshot"
? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore)
: Effect.void,
(event) => {
if (event._tag === "snapshot") {
return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore);
}
if (event._tag === "remoteUpdated") {
return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore);
}
return Effect.void;
},
).pipe(Effect.forkIn(scope));

yield* Deferred.await(snapshotDeferred);
Expand All @@ -578,10 +627,18 @@ describe("VcsStatusBroadcaster", () => {
yield* TestClock.adjust(Duration.seconds(59));
assert.equal(state.remoteStatusCalls, 1);

state.currentRemoteStatus = {
...baseRemoteStatus,
remoteRefHash: "remote-ref-hash-2",
};
yield* TestClock.adjust(Duration.seconds(1));
yield* Effect.yieldNow;
const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred);
assert.equal(state.remoteStatusCalls, 2);
assert.equal(state.remoteInvalidationCalls, 1);
assert.deepStrictEqual(remoteUpdated, {
_tag: "remoteUpdated",
remote: state.currentRemoteStatus,
} satisfies VcsStatusStreamEvent);

yield* Scope.close(scope, Exit.void);
}).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer())));
Expand Down
50 changes: 23 additions & 27 deletions apps/server/src/vcs/VcsStatusBroadcaster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,9 @@ interface CachedValue<T> {
}

interface CachedVcsStatus {
readonly local: CachedValue<VcsStatusLocalResult> | null;
readonly local: VcsStatusLocalResult | null;
readonly remote: CachedValue<VcsStatusRemoteResult | null> | null;
readonly localGeneration: number;
}

interface ActiveRemotePoller {
Expand Down Expand Up @@ -199,26 +200,25 @@ export const make = Effect.gen(function* () {

const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")(
function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) {
const nextLocal = {
fingerprint: fingerprintStatusPart(local),
value: local,
} satisfies CachedValue<VcsStatusLocalResult>;
const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
const previous = cache.get(cwd) ?? { local: null, remote: null };
const localGeneration = yield* Ref.modify(cacheRef, (cache) => {
const previous = cache.get(cwd) ?? { local: null, remote: null, localGeneration: 0 };
const nextLocalGeneration = previous.localGeneration + 1;
const nextCache = new Map(cache);
nextCache.set(cwd, {
...previous,
local: nextLocal,
local,
localGeneration: nextLocalGeneration,
});
return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const;
return [nextLocalGeneration, nextCache] as const;
});

if (options?.publish && shouldPublish) {
if (options?.publish) {
yield* PubSub.publish(changesPubSub, {
cwd,
event: {
_tag: "localUpdated",
local,
localGeneration,
},
});
}
Expand All @@ -234,7 +234,7 @@ export const make = Effect.gen(function* () {
value: remote,
} satisfies CachedValue<VcsStatusRemoteResult | null>;
const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
const previous = cache.get(cwd) ?? { local: null, remote: null };
const previous = cache.get(cwd) ?? { local: null, remote: null, localGeneration: 0 };
const nextCache = new Map(cache);
nextCache.set(cwd, {
...previous,
Expand Down Expand Up @@ -263,35 +263,30 @@ export const make = Effect.gen(function* () {
remote: VcsStatusRemoteResult | null,
options?: { publish?: boolean },
) {
const nextLocal = {
fingerprint: fingerprintStatusPart(local),
value: local,
} satisfies CachedValue<VcsStatusLocalResult>;
const nextRemote = {
fingerprint: fingerprintStatusPart(remote),
value: remote,
} satisfies CachedValue<VcsStatusRemoteResult | null>;
const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
const previous = cache.get(cwd) ?? { local: null, remote: null };
const localGeneration = yield* Ref.modify(cacheRef, (cache) => {
const previous = cache.get(cwd) ?? { local: null, remote: null, localGeneration: 0 };
const nextLocalGeneration = previous.localGeneration + 1;
const nextCache = new Map(cache);
nextCache.set(cwd, {
local: nextLocal,
local,
remote: nextRemote,
localGeneration: nextLocalGeneration,
});
return [
previous.local?.fingerprint !== nextLocal.fingerprint ||
previous.remote?.fingerprint !== nextRemote.fingerprint,
nextCache,
] as const;
return [nextLocalGeneration, nextCache] as const;
});

if (options?.publish && shouldPublish) {
if (options?.publish) {
yield* PubSub.publish(changesPubSub, {
cwd,
event: {
_tag: "snapshot",
local,
remote,
localGeneration,
},
});
}
Expand All @@ -311,7 +306,7 @@ export const make = Effect.gen(function* () {
) {
const cached = yield* getCachedStatus(cwd);
if (cached?.local) {
return cached.local.value;
return cached.local;
}
return yield* loadLocalStatus(cwd);
});
Expand All @@ -324,11 +319,11 @@ export const make = Effect.gen(function* () {
const cwd = yield* withFileSystem(normalizeCwd(input.cwd));
const cached = yield* getCachedStatus(cwd);
if (cached?.local && cached.remote) {
return mergeGitStatusParts(cached.local.value, cached.remote.value);
return mergeGitStatusParts(cached.local, cached.remote.value);
}
const [local, remote] = yield* Effect.all(
[
cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }),
cached?.local ? Effect.succeed(cached.local) : workflow.localStatus({ cwd }),
cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }),
],
{ concurrency: "unbounded" },
Expand Down Expand Up @@ -523,6 +518,7 @@ export const make = Effect.gen(function* () {
_tag: "snapshot" as const,
local: initialLocal,
remote: initialRemote,
localGeneration: cachedStatus?.localGeneration,
}),
Stream.fromSubscription(subscription).pipe(
Stream.filter((event) => event.cwd === cwd),
Expand Down
Loading
Loading