Skip to content
43 changes: 43 additions & 0 deletions apps/server/src/vcs/VcsStatusBroadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,49 @@ describe("VcsStatusBroadcaster", () => {
},
);

it.effect("publishes a checkout's branch before returning, ahead of the remote read", () => {
const state: Parameters<typeof layerTestFor>[0] = {
currentLocalStatus: baseLocalStatus,
currentRemoteStatus: baseRemoteStatus,
localStatusCalls: 0,
remoteStatusCalls: 0,
localInvalidationCalls: 0,
remoteInvalidationCalls: 0,
};

return Effect.gen(function* () {
const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
yield* broadcaster.getStatus({ cwd: "/repo" });
const subscribed = yield* Deferred.make<void>();
const localPublished = yield* Deferred.make<VcsStatusLocalResult>();
const remotePublished = yield* Deferred.make<VcsStatusRemoteResult | null>();
yield* broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }).pipe(
Stream.runForEach((event) => {
if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined);
if (event._tag === "localUpdated") return Deferred.succeed(localPublished, event.local);
return event.remote === null
? Effect.void
: Deferred.succeed(remotePublished, event.remote);
}),
Effect.forkScoped,
);
yield* Deferred.await(subscribed);

const releaseRemote = yield* Deferred.make<void>();
state.beforeRemoteStatus = Deferred.await(releaseRemote);
state.currentLocalStatus = { ...baseLocalStatus, refName: "main" };
state.currentRemoteStatus = remoteStatusWithPr;
yield* broadcaster.refreshAfterCheckout("/repo");

assert.equal((yield* Deferred.await(localPublished)).refName, "main");
assert.isFalse(yield* Deferred.isDone(remotePublished));

yield* Deferred.succeed(releaseRemote, undefined);
assert.deepStrictEqual(yield* Deferred.await(remotePublished), remoteStatusWithPr);
assert.equal((yield* broadcaster.getStatus({ cwd: "/repo" })).pr?.number, 2978);
}).pipe(Effect.provide(layerTestFor(state)), Effect.scoped);
});

it.effect("a remote poll refreshes Changes totals when divergence counts stay the same", () => {
const state: Parameters<typeof layerTestFor>[0] = {
currentLocalStatus: {
Expand Down
61 changes: 45 additions & 16 deletions apps/server/src/vcs/VcsStatusBroadcaster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ export class VcsStatusBroadcaster extends Context.Service<
readonly refreshLocalStatus: (
cwd: string,
) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
/**
* For a checkout someone is waiting on. Returns once the new branch is published, so the
* caller's reply does not outrun it, and finishes the slower remote read in the background.
*/
readonly refreshAfterCheckout: (cwd: string) => Effect.Effect<void>;
readonly refreshStatus: (
cwd: string,
options?: { readonly localOnly?: boolean },
Expand Down Expand Up @@ -603,6 +608,30 @@ export const make = Effect.gen(function* () {
);
});

const refreshRemoteAndLocalTotals = Effect.fn("VcsStatusBroadcaster.refreshRemoteAndLocalTotals")(
function* (cwd: string, policyCwd: string) {
return yield* withRemoteWriteLock(
cwd,
Effect.gen(function* () {
const refreshUpstream = !Duration.isZero(yield* automaticFetchInterval);
// Bump the PR lookup epoch after earlier remote writers have finished.
yield* workflow.invalidateStatus(cwd);
const remoteRead = yield* readRemoteStatus(cwd, { refreshUpstream });
// Fetch can move the base used for local Changes totals.
yield* refreshLocalStatusCore(cwd);
// Without a fetch, the cached behind count cannot justify an automatic pull.
const pulled = refreshUpstream
? yield* maybeAutoPull(cwd, remoteRead, [policyCwd])
: null;
if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote);
yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true });
const parts = yield* getStatusParts(cwd);
return mergeGitStatusParts(parts.local, parts.remote);
}),
);
},
);

const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn(
"VcsStatusBroadcaster.refreshStatus",
)(function* (rawCwd, options) {
Expand All @@ -613,23 +642,22 @@ export const make = Effect.gen(function* () {
const parts = yield* getStatusParts(cwd);
return mergeGitStatusParts(parts.local, parts.remote);
}
return yield* withRemoteWriteLock(
cwd,
Effect.gen(function* () {
const refreshUpstream = !Duration.isZero(yield* automaticFetchInterval);
// Bump the PR lookup epoch after earlier remote writers have finished.
yield* workflow.invalidateStatus(cwd);
const remoteRead = yield* readRemoteStatus(cwd, { refreshUpstream });
// Fetch can move the base used for local Changes totals.
yield* refreshLocalStatusCore(cwd);
// Without a fetch, the cached behind count cannot justify an automatic pull.
const pulled = refreshUpstream ? yield* maybeAutoPull(cwd, remoteRead, [rawCwd]) : null;
if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote);
yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true });
const parts = yield* getStatusParts(cwd);
return mergeGitStatusParts(parts.local, parts.remote);
}),
return yield* refreshRemoteAndLocalTotals(cwd, rawCwd);
});

const refreshAfterCheckout: VcsStatusBroadcaster["Service"]["refreshAfterCheckout"] = Effect.fn(
"VcsStatusBroadcaster.refreshAfterCheckout",
)(function* (rawCwd) {
const cwd = yield* withFileSystem(normalizeCwd(rawCwd));
const localDone = yield* Deferred.make<void>();
// Owned by the broadcaster so a client disconnecting mid-switch still leaves status correct.
yield* refreshLocalStatusCore(cwd).pipe(
Effect.ensuring(Deferred.succeed(localDone, undefined)),
Effect.andThen(refreshRemoteAndLocalTotals(cwd, rawCwd)),
Effect.ignoreCause({ log: true }),
Effect.forkIn(broadcasterScope),
);
yield* Deferred.await(localDone);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});

const refreshPullRequestStatus: VcsStatusBroadcaster["Service"]["refreshPullRequestStatus"] =
Expand Down Expand Up @@ -1122,6 +1150,7 @@ export const make = Effect.gen(function* () {
),
getStatus,
refreshLocalStatus,
refreshAfterCheckout,
refreshStatus: shareQueuedRefreshStatus,
refreshPullRequestStatus,
streamStatus,
Expand Down
14 changes: 12 additions & 2 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3172,9 +3172,19 @@ export const layerWsRpc = (
),
),
[WS_METHODS.vcsCreateRef]: (input) =>
gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
gitWorkflow
.createRef(input)
.pipe(
Effect.tap(() =>
input.switchRef
? vcsStatusBroadcaster.refreshAfterCheckout(input.cwd)
: refreshGitStatus(input.cwd),
),
),
[WS_METHODS.vcsSwitchRef]: (input) =>
gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
gitWorkflow
.switchRef(input)
.pipe(Effect.tap(() => vcsStatusBroadcaster.refreshAfterCheckout(input.cwd))),
[WS_METHODS.vcsInit]: (input) =>
vcsProvisioning.initRepository(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
[WS_METHODS.reviewGetBranchCommits]: (input) => review.getBranchCommits(input),
Expand Down
57 changes: 56 additions & 1 deletion apps/web/src/components/BranchToolbar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ProviderInstanceId, RunId, type VcsRef } from "@t3tools/contracts";
import { EnvironmentId, ProviderInstanceId, RunId, type VcsRef } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
import {
Expand All @@ -13,6 +13,7 @@ import {
resolveLockedWorkspaceLabel,
sanitizeNewRefName,
shouldIncludeBranchPickerItem,
isBranchHoldCurrent,
} from "./BranchToolbar.logic";

describe("resolveDraftEnvModeAfterBranchChange", () => {
Expand Down Expand Up @@ -332,6 +333,60 @@ describe("shouldIncludeBranchPickerItem", () => {
});
});

describe("isBranchHoldCurrent", () => {
const environmentId = EnvironmentId.make("env-a");
const hold = {
branch: "feature",
environmentId,
checkouts: [{ cwd: "/repo", replaces: "main" }],
};
const current = { canonicalBranch: "main", environmentId, cwd: "/repo" };

it("keeps showing the checked-out branch while status still reports the old one", () => {
expect(isBranchHoldCurrent(hold, current)).toBe(true);
});

it("lets go once status moves, even to a different branch than the checkout", () => {
for (const canonicalBranch of ["feature", "other", null]) {
expect(isBranchHoldCurrent(hold, { ...current, canonicalBranch })).toBe(false);
}
});

it("does not apply to another checkout that happens to be on the old branch", () => {
expect(isBranchHoldCurrent(hold, { ...current, cwd: "/other" })).toBe(false);
});

it("follows a thread moving from its worktree to the project root", () => {
const moveHold = {
branch: "main",
environmentId,
checkouts: [
{ cwd: "/repo-feature", replaces: "feature" },
{ cwd: "/repo", replaces: "dev" },
],
};
expect(
isBranchHoldCurrent(moveHold, {
environmentId,
cwd: "/repo-feature",
canonicalBranch: "feature",
}),
).toBe(true);
expect(
isBranchHoldCurrent(moveHold, { environmentId, cwd: "/repo", canonicalBranch: "dev" }),
).toBe(true);
expect(
isBranchHoldCurrent(moveHold, { environmentId, cwd: "/repo", canonicalBranch: "main" }),
).toBe(false);
});

it("does not apply to the same path on another environment", () => {
expect(
isBranchHoldCurrent(hold, { ...current, environmentId: EnvironmentId.make("env-b") }),
).toBe(false);
});
});

describe("sanitizeNewRefName", () => {
it("collapses a run of whitespace into a single dash", () => {
expect(sanitizeNewRefName("new branch")).toBe("new-branch");
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,36 @@ export function resolveBranchToolbarValue(input: {
return currentGitBranch ?? activeThreadBranch;
}

export interface BranchHold {
readonly branch: string | null;
readonly environmentId: EnvironmentId;
/**
* Each checkout the thread can show before it catches up, with the branch its status reported
* before the switch. A move from a worktree to the project root lists both.
*/
readonly checkouts: ReadonlyArray<{ readonly cwd: string; readonly replaces: string | null }>;
}

/**
* A finished checkout's branch stays on screen until status reports any change, because the status
* stream and the RPC reply are not ordered and showing status first would flip back to the old branch.
*/
export function isBranchHoldCurrent(
hold: BranchHold,
current: {
readonly canonicalBranch: string | null;
readonly environmentId: EnvironmentId;
readonly cwd: string | null;
},
): boolean {
return (
hold.environmentId === current.environmentId &&
hold.checkouts.some(
(checkout) => checkout.cwd === current.cwd && checkout.replaces === current.canonicalBranch,
)
);
}

/**
* The base a New worktree thread gets without the user picking one. Only a listed ref qualifies:
* during a clone the status names an unborn HEAD that no ref backs.
Expand Down
63 changes: 54 additions & 9 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ import { parsePullRequestReference } from "@t3tools/client-runtime/pull-request-
import { getSourceControlPresentation } from "../sourceControlPresentation";
import { useComposerMenuProps } from "./chat/composerEventScope";
import {
type BranchHold,
deriveLocalBranchNameFromRemoteRef,
isBranchHoldCurrent,
isWorktreeChangeBlocked,
resolveBranchTriggerLabel,
resolveBranchToolbarPrBranch,
Expand Down Expand Up @@ -180,6 +182,7 @@ export function BranchToolbarBranchSelector({
branch: string | null,
worktreePath: string | null,
automatic = false,
onMetadataFailure?: () => void,
) => {
if (
!activeThreadId ||
Expand Down Expand Up @@ -216,6 +219,8 @@ export function BranchToolbarBranchSelector({
worktreePath,
...(automatic ? { expectedEmpty: true } : {}),
},
}).then((result) => {
if (result._tag !== "Success") onMetadataFailure?.();
});
}
if (hasServerThread) {
Expand Down Expand Up @@ -326,8 +331,17 @@ export function BranchToolbarBranchSelector({
activeThreadBranch,
currentGitBranch,
});
const [branchHold, setBranchHold] = useState<BranchHold | null>(null);
const branchHoldCurrent =
branchHold !== null &&
isBranchHoldCurrent(branchHold, {
canonicalBranch: canonicalActiveBranch,
environmentId,
cwd: branchCwd,
});
if (branchHold !== null && !branchHoldCurrent) setBranchHold(null);
const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic(
canonicalActiveBranch,
branchHoldCurrent ? branchHold.branch : canonicalActiveBranch,
(_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch,
);
const listedActiveBranch =
Expand Down Expand Up @@ -406,7 +420,6 @@ export function BranchToolbarBranchSelector({
startBranchActionTransition(async () => {
await action();
branchRefState.refresh();
branchStatusQuery.refresh();
});
};

Expand Down Expand Up @@ -468,11 +481,37 @@ export function BranchToolbarBranchSelector({
const nextBranchName = refName.isRemote
? (checkoutResult.value.refName ?? selectedBranchName)
: selectedBranchName;
setOptimisticBranch(
setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath)
? nextBranchName
: previousBranch,
const movesCheckout = selectionTarget.checkoutCwd !== branchCwd;
let hold: BranchHold | null = null;
const applied = setThreadBranch(
nextBranchName,
selectionTarget.nextWorktreePath,
false,
// A failed move leaves the thread in its old checkout, which never had the new branch.
movesCheckout
? () => setBranchHold((current) => (current === hold ? null : current))
: undefined,
);
if (applied) {
hold = {
branch: nextBranchName,
environmentId,
// The thread keeps its old checkout until the metadata update moves it to the new one.
checkouts: [
{ cwd: branchCwd, replaces: canonicalActiveBranch },
Comment thread
Yuruzuu marked this conversation as resolved.
Resolved
...(movesCheckout
? [
{
cwd: selectionTarget.checkoutCwd,
replaces: projectStatusQuery.data?.refName ?? null,
},
]
: []),
],
};
setBranchHold(hold);
}
setOptimisticBranch(applied ? nextBranchName : previousBranch);
return;
}
setOptimisticBranch(previousBranch);
Expand Down Expand Up @@ -509,9 +548,15 @@ export function BranchToolbarBranchSelector({
});
if (createBranchResult._tag === "Success") {
const nextBranchName = createBranchResult.value.refName;
setOptimisticBranch(
setThreadBranch(nextBranchName, activeWorktreePath) ? nextBranchName : previousBranch,
);
const applied = setThreadBranch(nextBranchName, activeWorktreePath);
if (applied) {
setBranchHold({
branch: nextBranchName,
environmentId,
checkouts: [{ cwd: branchCwd, replaces: canonicalActiveBranch }],
});
}
setOptimisticBranch(applied ? nextBranchName : previousBranch);
return;
}
setOptimisticBranch(previousBranch);
Expand Down
Loading