diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index d10b09b1be961..c3df35b036f9a 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -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"; @@ -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")( diff --git a/apps/server/src/git/checkoutDetails.test.ts b/apps/server/src/git/checkoutDetails.test.ts new file mode 100644 index 0000000000000..27546efa46683 --- /dev/null +++ b/apps/server/src/git/checkoutDetails.test.ts @@ -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)), + ), + ), + ), +); diff --git a/apps/server/src/git/checkoutDetails.ts b/apps/server/src/git/checkoutDetails.ts new file mode 100644 index 0000000000000..3b8e8c6ad575d --- /dev/null +++ b/apps/server/src/git/checkoutDetails.ts @@ -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, + }; +}); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index e70afb2ddde02..22c47f8184b56 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -81,6 +81,7 @@ function layerTestFor(state: { automaticFetchInterval?: Effect.Effect; beforeRemoteStatus?: Effect.Effect; beforeLocalStatus?: Effect.Effect; + localStatusIncludeCheckoutDetailsValues?: Array; beforeCurrentBranch?: Effect.Effect; beforeInvalidate?: Effect.Effect; branchRead?: () => Effect.Effect; @@ -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( @@ -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)), @@ -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[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(); + const published = yield* Deferred.make(); + 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[0] = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index ffe685ef60d57..53af6f3d00780 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -308,6 +308,31 @@ export const make = Effect.gen(function* () { const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => remoteWriteLocks.withLock(cwd, effect); const pollersRef = yield* SynchronizedRef.make(new Map()); + const checkoutDetailDemandRef = yield* Ref.make(new Map()); + + 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()); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( @@ -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, }); } @@ -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 }); }), @@ -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, @@ -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 }) + : 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)); }), ); diff --git a/apps/web/src/components/sidebar/SessionWorkspaceDetails.tsx b/apps/web/src/components/sidebar/SessionWorkspaceDetails.tsx new file mode 100644 index 0000000000000..0ef8872dd3953 --- /dev/null +++ b/apps/web/src/components/sidebar/SessionWorkspaceDetails.tsx @@ -0,0 +1,96 @@ +import { type EnvironmentId } from "@t3tools/contracts"; +import { GitBranchIcon } from "lucide-react"; +import { useEnvironmentQuery } from "../../state/query"; +import { vcsEnvironment } from "../../state/vcs"; +import type { SidebarThreadSummary } from "../../types"; + +function CheckoutDetails({ + environmentId, + cwd, + heading, +}: { + environmentId: EnvironmentId; + cwd: string; + heading: string; +}) { + const query = useEnvironmentQuery( + vcsEnvironment.status({ + environmentId, + input: { cwd, includeRemote: false, includeCheckoutDetails: true }, + }), + ); + const status = query.data; + const details = status?.checkoutDetails; + const date = (value: string | null) => (value ? new Date(value).toLocaleString() : "Unavailable"); + return ( +
+ +
{heading}
+
+
{cwd}
+ {query.error ? ( +
{query.error}
+ ) : status === null ? ( +
Loading checkout details…
+ ) : status.isRepo === false ? ( +
No Git repository
+ ) : ( + <> +
Branch: {status?.refName ?? "Loading…"}
+
+ Version:{" "} + {details?.commit.slice(0, 10) ?? "Unavailable"} +
+
Version committed: {date(details?.committedAt ?? null)}
+
Checkout last updated: {date(details?.headUpdatedAt ?? null)}
+
+ {details?.behindOriginMain == null + ? "origin/main comparison unavailable" + : details.behindOriginMain === 0 + ? "Up to date with origin/main" + : `${details.behindOriginMain} commits behind origin/main`} +
+ {status?.hasWorkingTreeChanges ?
Uncommitted changes
: null} +
+ Compared with the locally fetched origin/main. +
+ + )} +
+
+ ); +} + +export function SessionWorkspaceDetails({ + thread, + workspaceRoot, +}: { + thread: SidebarThreadSummary; + workspaceRoot: string | null; +}) { + const cwd = thread.worktreePath ?? workspaceRoot; + return ( +
+ {cwd ? ( + + ) : ( + Workspace unavailable + )} + {thread.worktreePath && workspaceRoot && workspaceRoot !== cwd ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 89ab26f51b9a0..11a771422b1c7 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1,6 +1,7 @@ import { useAtomMount } from "@effect/atom-react"; import { asLaunchingThread } from "@t3tools/client-runtime/state/thread-inbox"; import { ThreadHoverCard, ThreadHoverCardPopup } from "../ThreadHoverCard"; +import { SessionWorkspaceDetails } from "./SessionWorkspaceDetails"; import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; import { discardComposerDraft } from "../../lib/discardComposerDraft"; import { requestCustomSnooze } from "../CustomSnoozeDialog"; @@ -330,6 +331,7 @@ function SidebarThreadTooltip({ ) : null} + {previousProviderNames.length > 0 ? (
diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 45d68c5e9ca44..041fa68048633 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -107,6 +107,7 @@ const GitResolvedPullRequest = Schema.Struct({ export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; export const VcsStatusInput = Schema.Struct({ + includeCheckoutDetails: Schema.optional(Schema.Boolean), cwd: TrimmedNonEmptyStringSchema, }); export type VcsStatusInput = typeof VcsStatusInput.Type; @@ -221,6 +222,15 @@ const VcsStatusChangeRequest = Schema.Struct({ }); const VcsStatusLocalShape = { + checkoutDetails: Schema.optional( + Schema.Struct({ + commit: Schema.String, + committedAt: Schema.String, + headUpdatedAt: Schema.NullOr(Schema.String), + behindOriginMain: Schema.NullOr(NonNegativeInt), + originMainCommit: Schema.NullOr(Schema.String), + }), + ), isRepo: Schema.Boolean, sourceControlProvider: Schema.optional(SourceControlProviderInfo), hasPrimaryRemote: Schema.Boolean, diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index b549228712301..aa5662f3fd0c6 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -277,6 +277,13 @@ describe("applyGitStatusStreamEvent", () => { it("preserves local-only fields when applying a remote update", () => { const current: VcsStatusResult = { + checkoutDetails: { + commit: "0123456789abcdef", + committedAt: "2026-11-04T09:10:28+01:00", + headUpdatedAt: "2026-11-04T09:15:00+01:00", + behindOriginMain: 15, + originMainCommit: "abcdef0123456789", + }, isRepo: true, sourceControlProvider: { kind: SourceControlProviderKind.make("github"), diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index ee045c35bdd2f..e84d3c447342f 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -341,6 +341,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { return { + ...(status.checkoutDetails ? { checkoutDetails: status.checkoutDetails } : {}), isRepo: status.isRepo, ...(status.sourceControlProvider ? { sourceControlProvider: status.sourceControlProvider }