From 00bba4137436116744b11463fd1fb200d57d23a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 2 Oct 2026 13:29:42 +0100 Subject: [PATCH 1/2] feat(source-control): add version control panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Co-authored by GPT-6 in Codex via T3 Code --- .../desktop/src/electron/ElectronMenu.test.ts | 29 + apps/mobile/src/Stack.tsx | 20 + .../connection/background-activity-scopes.ts | 41 +- .../connection/background-activity.test.ts | 11 + .../src/connection/background-activity.ts | 10 + .../features/threads/ThreadGitControls.tsx | 28 + .../features/threads/git/GitBranchesSheet.tsx | 4 +- .../features/threads/git/GitOverviewSheet.tsx | 13 + .../src/features/threads/threadListV2.ts | 5 +- .../VersionControlCommitFiles.tsx | 54 + .../VersionControlDiffRouteScreen.tsx | 171 ++ .../version-control/VersionControlList.tsx | 31 + .../VersionControlRouteComponents.tsx | 321 +++ .../VersionControlRouteScreen.tsx | 954 ++++++++ .../VersionControlRouteView.tsx | 1006 ++++++++ .../useVersionControlPanelApi.test.ts | 51 + .../useVersionControlPanelApi.ts | 217 ++ .../versionControlModel.test.ts | 468 ++++ .../version-control/versionControlModel.ts | 345 +++ .../versionControlRequest.test.ts | 111 + .../version-control/versionControlRequest.ts | 69 + apps/mobile/src/state/use-atom-command.ts | 3 +- .../mobile/src/state/use-atom-query-runner.ts | 7 +- .../state/use-selected-thread-git-actions.ts | 35 +- apps/server/src/auth/RpcAuthorization.ts | 30 + apps/server/src/diagnostics/ErrorCause.ts | 82 + apps/server/src/git/GitManager.test.ts | 120 +- apps/server/src/git/GitManager.ts | 176 +- apps/server/src/git/GitWorkflowService.ts | 7 +- apps/server/src/server.test.ts | 305 +-- apps/server/src/server.ts | 16 +- .../src/sourceControl/AzureDevOpsCli.test.ts | 116 + .../src/sourceControl/AzureDevOpsCli.ts | 69 +- .../AzureDevOpsSourceControlProvider.test.ts | 167 +- .../AzureDevOpsSourceControlProvider.ts | 237 +- .../src/sourceControl/BitbucketApi.test.ts | 16 + apps/server/src/sourceControl/BitbucketApi.ts | 54 +- .../BitbucketSourceControlProvider.test.ts | 60 + .../BitbucketSourceControlProvider.ts | 35 +- .../ForgejoSourceControlProvider.ts | 14 +- .../src/sourceControl/GitHubCli.test.ts | 44 + apps/server/src/sourceControl/GitHubCli.ts | 30 +- .../GitHubSourceControlProvider.test.ts | 106 +- .../GitHubSourceControlProvider.ts | 329 ++- .../src/sourceControl/GitLabCli.test.ts | 158 ++ apps/server/src/sourceControl/GitLabCli.ts | 100 +- .../GitLabSourceControlProvider.test.ts | 172 ++ .../GitLabSourceControlProvider.ts | 76 +- .../SourceControlPanelActions.ts | 1080 +++++++++ .../SourceControlPanelFetchPolicy.test.ts | 92 + .../SourceControlPanelFetchPolicy.ts | 40 + .../SourceControlPanelParsers.test.ts | 142 ++ .../SourceControlPanelParsers.ts | 411 ++++ .../SourceControlPanelReaders.test.ts | 472 ++++ .../SourceControlPanelReaders.ts | 987 ++++++++ ...SourceControlPanelService.Branches.test.ts | 1113 +++++++++ .../SourceControlPanelService.Core.test.ts | 2132 +++++++++++++++++ .../SourceControlPanelService.Diffs.test.ts | 1499 ++++++++++++ .../SourceControlPanelService.Refresh.test.ts | 643 +++++ ...SourceControlPanelService.Snapshot.test.ts | 933 ++++++++ .../SourceControlPanelService.ts | 1101 +++++++++ .../SourceControlPanelStatusParsers.ts | 340 +++ .../SourceControlProvider.test.ts | 48 +- .../sourceControl/SourceControlProvider.ts | 137 +- .../SourceControlProviderRegistry.ts | 13 + .../SourceControlRateLimit.test.ts | 107 + .../sourceControl/SourceControlRateLimit.ts | 59 + .../SourceControlRepositoryService.test.ts | 3 +- .../textGeneration/SourceControlWriting.ts | 125 + apps/server/src/utils/CanonicalPath.ts | 38 + apps/server/src/vcs/GitCommandTimeout.test.ts | 42 + apps/server/src/vcs/GitCommandTimeout.ts | 51 + apps/server/src/vcs/GitVcsDriver.test.ts | 43 + apps/server/src/vcs/GitVcsDriver.ts | 6 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 281 ++- apps/server/src/vcs/GitVcsDriverCore.ts | 338 +-- .../src/vcs/VcsCacheInterruption.test.ts | 41 + apps/server/src/vcs/VcsLocalWatch.ts | 33 + .../src/vcs/VcsStatusBroadcaster.test.ts | 434 +++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 188 +- apps/server/src/ws.ts | 257 +- apps/web/package.json | 1 + .../BranchToolbarBranchSelector.tsx | 14 +- .../web/src/components/ChatView.logic.test.ts | 88 +- apps/web/src/components/ChatView.logic.ts | 38 + .../components/ChatView.sourceControl.test.ts | 1071 +++++++++ .../src/components/ChatView.sourceControl.ts | 629 +++++ apps/web/src/components/ChatView.tsx | 741 ++++-- apps/web/src/components/GitActionsControl.tsx | 2 +- .../src/components/RightPanelTabs.test.tsx | 122 +- apps/web/src/components/RightPanelTabs.tsx | 397 +-- apps/web/src/components/Sidebar.tsx | 28 +- apps/web/src/components/WorkingIndicator.tsx | 10 + .../components/rightPanelBrowserTabState.ts | 51 + .../components/rightPanelSurfaceActions.ts | 276 +++ .../settings/SettingsPanels.logic.test.ts | 3 + .../settings/SettingsPanels.logic.ts | 2 + .../components/settings/SettingsPanels.tsx | 41 + .../settings/SourceControlSettings.tsx | 187 +- .../settings/settingsSearch.test.ts | 39 + .../src/components/settings/settingsSearch.ts | 20 + .../source-control/CommitFileChanges.tsx | 59 + .../SourceControlActionableItem.tsx | 59 + .../SourceControlEnvironmentPanel.tsx | 44 + .../SourceControlPanel.logic.test.ts | 946 ++++++++ .../SourceControlPanel.logic.ts | 423 ++++ .../source-control/SourceControlPanel.tsx | 286 +++ .../SourceControlPanelBranches.tsx | 957 ++++++++ .../source-control/SourceControlPanelCache.ts | 108 + .../source-control/SourceControlPanelModel.ts | 335 +++ .../SourceControlPanelPrimitives.tsx | 686 ++++++ .../SourceControlPanelRepositories.tsx | 448 ++++ .../SourceControlPanelRows.test.tsx | 25 + .../source-control/SourceControlPanelRows.tsx | 239 ++ .../source-control/SourceControlPanelView.tsx | 835 +++++++ .../SourceControlPanelWorkingTree.tsx | 733 ++++++ .../SourceControlVirtualList.tsx | 90 + .../useSourceControlPanelActions.tsx | 913 +++++++ .../useSourceControlPanelController.ts | 22 + .../useSourceControlPanelExpansion.tsx | 425 ++++ .../useSourceControlPanelRefresh.ts | 166 ++ .../useSourceControlPanelState.tsx | 939 ++++++++ apps/web/src/components/ui/tooltip.test.ts | 37 + apps/web/src/components/ui/tooltip.tsx | 91 +- apps/web/src/contextMenuFallback.ts | 20 +- apps/web/src/diffFileActions.test.ts | 23 +- apps/web/src/index.css | 3 + .../lib/backgroundActivityReporter.test.ts | 11 + .../web/src/lib/backgroundActivityReporter.ts | 22 +- apps/web/src/lib/openPullRequestLink.ts | 25 +- apps/web/src/rightPanelStore.test.ts | 281 ++- apps/web/src/rightPanelStore.ts | 126 +- apps/web/src/routes/_chat.pull-requests.tsx | 2 + apps/web/src/state/sourceControlActions.ts | 4 +- .../state/sourceControlPanel.fetch.test.ts | 61 + apps/web/src/state/sourceControlPanel.test.ts | 99 + apps/web/src/state/sourceControlPanel.ts | 381 +++ apps/web/src/state/use-atom-command.ts | 3 +- apps/web/src/state/use-atom-query-runner.ts | 7 +- docs/user/source-control.md | 12 + .../client-runtime/src/state/runtime.test.ts | 38 + packages/client-runtime/src/state/runtime.ts | 3 +- .../client-runtime/src/state/threadSettled.ts | 1 - packages/client-runtime/src/state/vcs.test.ts | 212 ++ packages/client-runtime/src/state/vcs.ts | 206 +- .../src/state/vcsCommandScheduler.test.ts | 89 + .../src/state/vcsCommandScheduler.ts | 16 + packages/contracts/src/git.test.ts | 30 + packages/contracts/src/git.ts | 355 ++- packages/contracts/src/rpc.ts | 258 ++ packages/contracts/src/settings.test.ts | 70 + packages/contracts/src/settings.ts | 47 +- .../shared/src/backgroundActivitySettings.ts | 41 +- packages/shared/src/serverSettings.test.ts | 81 + packages/shared/src/sourceControl.test.ts | 9 + packages/shared/src/sourceControl.ts | 161 ++ pnpm-lock.yaml | 20 + 157 files changed, 33696 insertions(+), 1399 deletions(-) create mode 100644 apps/mobile/src/features/version-control/VersionControlCommitFiles.tsx create mode 100644 apps/mobile/src/features/version-control/VersionControlDiffRouteScreen.tsx create mode 100644 apps/mobile/src/features/version-control/VersionControlList.tsx create mode 100644 apps/mobile/src/features/version-control/VersionControlRouteComponents.tsx create mode 100644 apps/mobile/src/features/version-control/VersionControlRouteScreen.tsx create mode 100644 apps/mobile/src/features/version-control/VersionControlRouteView.tsx create mode 100644 apps/mobile/src/features/version-control/useVersionControlPanelApi.test.ts create mode 100644 apps/mobile/src/features/version-control/useVersionControlPanelApi.ts create mode 100644 apps/mobile/src/features/version-control/versionControlModel.test.ts create mode 100644 apps/mobile/src/features/version-control/versionControlModel.ts create mode 100644 apps/mobile/src/features/version-control/versionControlRequest.test.ts create mode 100644 apps/mobile/src/features/version-control/versionControlRequest.ts create mode 100644 apps/server/src/diagnostics/ErrorCause.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelActions.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelFetchPolicy.test.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelFetchPolicy.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelParsers.test.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelParsers.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelReaders.test.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelReaders.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelService.Branches.test.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelService.Core.test.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelService.Diffs.test.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelService.Refresh.test.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelService.Snapshot.test.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelService.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelStatusParsers.ts create mode 100644 apps/server/src/textGeneration/SourceControlWriting.ts create mode 100644 apps/server/src/utils/CanonicalPath.ts create mode 100644 apps/server/src/vcs/GitCommandTimeout.test.ts create mode 100644 apps/server/src/vcs/GitCommandTimeout.ts create mode 100644 apps/server/src/vcs/VcsCacheInterruption.test.ts create mode 100644 apps/server/src/vcs/VcsLocalWatch.ts create mode 100644 apps/web/src/components/ChatView.sourceControl.test.ts create mode 100644 apps/web/src/components/ChatView.sourceControl.ts create mode 100644 apps/web/src/components/WorkingIndicator.tsx create mode 100644 apps/web/src/components/rightPanelBrowserTabState.ts create mode 100644 apps/web/src/components/rightPanelSurfaceActions.ts create mode 100644 apps/web/src/components/source-control/CommitFileChanges.tsx create mode 100644 apps/web/src/components/source-control/SourceControlActionableItem.tsx create mode 100644 apps/web/src/components/source-control/SourceControlEnvironmentPanel.tsx create mode 100644 apps/web/src/components/source-control/SourceControlPanel.logic.test.ts create mode 100644 apps/web/src/components/source-control/SourceControlPanel.logic.ts create mode 100644 apps/web/src/components/source-control/SourceControlPanel.tsx create mode 100644 apps/web/src/components/source-control/SourceControlPanelBranches.tsx create mode 100644 apps/web/src/components/source-control/SourceControlPanelCache.ts create mode 100644 apps/web/src/components/source-control/SourceControlPanelModel.ts create mode 100644 apps/web/src/components/source-control/SourceControlPanelPrimitives.tsx create mode 100644 apps/web/src/components/source-control/SourceControlPanelRepositories.tsx create mode 100644 apps/web/src/components/source-control/SourceControlPanelRows.test.tsx create mode 100644 apps/web/src/components/source-control/SourceControlPanelRows.tsx create mode 100644 apps/web/src/components/source-control/SourceControlPanelView.tsx create mode 100644 apps/web/src/components/source-control/SourceControlPanelWorkingTree.tsx create mode 100644 apps/web/src/components/source-control/SourceControlVirtualList.tsx create mode 100644 apps/web/src/components/source-control/useSourceControlPanelActions.tsx create mode 100644 apps/web/src/components/source-control/useSourceControlPanelController.ts create mode 100644 apps/web/src/components/source-control/useSourceControlPanelExpansion.tsx create mode 100644 apps/web/src/components/source-control/useSourceControlPanelRefresh.ts create mode 100644 apps/web/src/components/source-control/useSourceControlPanelState.tsx create mode 100644 apps/web/src/components/ui/tooltip.test.ts create mode 100644 apps/web/src/state/sourceControlPanel.fetch.test.ts create mode 100644 apps/web/src/state/sourceControlPanel.test.ts create mode 100644 apps/web/src/state/sourceControlPanel.ts create mode 100644 packages/client-runtime/src/state/vcsCommandScheduler.test.ts diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 2da7d6689599..c5bbd89c14b4 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -148,6 +148,35 @@ describe("ElectronMenu", () => { }).pipe(Effect.provide(TestLayer)), ); + it.effect("inserts a destructive separator when the first destructive item starts the menu", () => + Effect.gen(function* () { + buildFromTemplateMock.mockImplementation(() => ({ + popup: (options: Electron.PopupOptions) => { + options.callback?.(); + }, + })); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + yield* electronMenu.showContextMenu({ + window: makeWindow(), + items: [ + { id: "delete-draft", label: "Delete draft", destructive: true }, + { id: "copy", label: "Copy" }, + { id: "delete-file", label: "Delete file", destructive: true }, + ], + position: Option.none(), + }); + + const template = buildFromTemplateMock.mock.calls[0]?.[0] as + | Electron.MenuItemConstructorOptions[] + | undefined; + assert.deepEqual( + template?.map((item) => item.type ?? item.label), + ["Delete draft", "Copy", "separator", "Delete file"], + ); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("defers popupTemplate side effects until the returned Effect runs", () => Effect.gen(function* () { const popupMock = vi.fn(); diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 417473ba20e8..de015fb43aab 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -47,6 +47,8 @@ import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; +import { VersionControlRouteScreen } from "./features/version-control/VersionControlRouteScreen"; +import { VersionControlDiffRouteScreen } from "./features/version-control/VersionControlDiffRouteScreen"; import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; @@ -478,6 +480,8 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "GitCommit", "GitConfirm", "GitOverview", + "VersionControl", + "VersionControlDiff", "NewTaskSheet", "SettingsLegal", "SettingsSheet", @@ -705,6 +709,22 @@ const RootStackConfig = createNativeStackNavigator({ sheetGrabberVisible: true, }, }), + VersionControl: createNativeStackScreen({ + screen: VersionControlRouteScreen, + linking: `${THREAD_LINKING_PREFIX}/version-control`, + options: { + ...GLASS_HEADER_OPTIONS, + presentation: "fullScreenModal", + title: "Version Control", + }, + }), + VersionControlDiff: createNativeStackScreen({ + screen: VersionControlDiffRouteScreen, + options: { + ...SOLID_HEADER_OPTIONS, + title: "Diff", + }, + }), GitCommit: createNativeStackScreen({ screen: GitCommitSheet, linking: `${THREAD_LINKING_PREFIX}/git/commit`, diff --git a/apps/mobile/src/connection/background-activity-scopes.ts b/apps/mobile/src/connection/background-activity-scopes.ts index da013a572191..94c488ebfbe3 100644 --- a/apps/mobile/src/connection/background-activity-scopes.ts +++ b/apps/mobile/src/connection/background-activity-scopes.ts @@ -49,6 +49,29 @@ function scopeForSubscription( return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; } +export function retainMobileBackgroundActivityScope( + environmentId: EnvironmentId, + scope: BackgroundScope, +): () => void { + const key = stableScopeKey(environmentId, scope); + const current = retainedScopes.get(key); + if (current) { + current.refCount += 1; + } else { + retainedScopes.set(key, { environmentId, scope, refCount: 1 }); + notify(); + } + return () => { + const retained = retainedScopes.get(key); + if (!retained) return; + retained.refCount -= 1; + if (retained.refCount <= 0) { + retainedScopes.delete(key); + notify(); + } + }; +} + export function retainedMobileBackgroundScopes( environmentId: EnvironmentId, ): ReadonlyArray { @@ -64,23 +87,7 @@ export function observeMobileBackgroundActivitySubscription( if (scope === null) return Effect.succeed(Effect.void); return Effect.sync(() => { const environmentId = observation.environmentId as EnvironmentId; - const key = stableScopeKey(environmentId, scope); - const current = retainedScopes.get(key); - if (current) { - current.refCount += 1; - } else { - retainedScopes.set(key, { environmentId, scope, refCount: 1 }); - notify(); - } - return Effect.sync(() => { - const retained = retainedScopes.get(key); - if (!retained) return; - retained.refCount -= 1; - if (retained.refCount <= 0) { - retainedScopes.delete(key); - notify(); - } - }); + return Effect.sync(retainMobileBackgroundActivityScope(environmentId, scope)); }); } diff --git a/apps/mobile/src/connection/background-activity.test.ts b/apps/mobile/src/connection/background-activity.test.ts index 7a2d902557ed..cf8bec46c530 100644 --- a/apps/mobile/src/connection/background-activity.test.ts +++ b/apps/mobile/src/connection/background-activity.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import { onRetainedMobileBackgroundScopesChange, observeMobileBackgroundActivitySubscription, + retainMobileBackgroundActivityScope, retainedMobileBackgroundScopes, } from "./background-activity-scopes"; @@ -74,4 +75,14 @@ describe("mobile background activity", () => { removeListener(); }), ); + + it("retains Version Control git-ref demand explicitly", () => { + const environmentId = EnvironmentId.make("mobile-version-control-panel"); + const scope = { type: "git-refs" as const, cwd: "/workspace" }; + const release = retainMobileBackgroundActivityScope(environmentId, scope); + + expect(retainedMobileBackgroundScopes(environmentId)).toEqual([scope]); + release(); + expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]); + }); }); diff --git a/apps/mobile/src/connection/background-activity.ts b/apps/mobile/src/connection/background-activity.ts index 6ecc8d81f464..a92a78a65fac 100644 --- a/apps/mobile/src/connection/background-activity.ts +++ b/apps/mobile/src/connection/background-activity.ts @@ -26,6 +26,11 @@ import { const REPORT_INTERVAL_MS = 25_000; const LEASE_TTL_MS = 45_000; const BASELINE_SCOPES: ReadonlyArray = [{ type: "provider-status" }]; +let immediateReporter: (() => Promise) | null = null; + +export async function flushMobileBackgroundActivityReport(): Promise { + await immediateReporter?.(); +} // `AppState.currentState` is a loosely typed string that can be unset before // the first change event; anything outside the known states reports as unknown. @@ -85,9 +90,13 @@ export const mobileBackgroundActivityReporterLayer = Layer.effectDiscard( { concurrency: "unbounded", discard: true }, ); }).pipe(Effect.withSpan("mobile.backgroundActivity.report")); + const runtimeContext = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(runtimeContext); + const reportImmediately = () => runPromise(report); yield* Effect.acquireRelease( Effect.sync(() => { + immediateReporter = reportImmediately; const removeScopeListener = onRetainedMobileBackgroundScopesChange(requestReport); const subscription = AppState.addEventListener("change", (nextState) => { appState = nextState; @@ -97,6 +106,7 @@ export const mobileBackgroundActivityReporterLayer = Layer.effectDiscard( }), ({ removeScopeListener, subscription }) => Effect.sync(() => { + if (immediateReporter === reportImmediately) immediateReporter = null; removeScopeListener(); subscription.remove(); }), diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index e3a4033cd809..df027d2db4d8 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -235,12 +235,20 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { }); }, [environmentId, props.onOpenGitInspector, navigation, threadId]); + const openVersionControl = useCallback(() => { + navigation.navigate("VersionControl", { + environmentId: String(environmentId), + threadId: String(threadId), + }); + }, [environmentId, navigation, threadId]); + return { currentBranchLabel, isRepo, openFiles, openGitInspector, openReview, + openVersionControl, quickAction, quickActionHint, quickActionIcon, @@ -346,6 +354,17 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit onPress: (): void => void model.runQuickAction(), type: "action", }, + { + description: "Actionable branches, stashes, and remotes", + disabled: !model.isRepo, + icon: { + name: "point.topleft.down.curvedto.point.bottomright.up", + type: "sfSymbol", + }, + label: "Version Control", + onPress: model.openVersionControl, + type: "action", + }, { description: "Turn diffs and worktree changes", disabled: !model.isRepo, @@ -375,6 +394,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit model.openFiles, model.openGitInspector, model.openReview, + model.openVersionControl, model.quickAction.disabled, model.quickAction.label, model.quickActionHint, @@ -539,6 +559,14 @@ function threadGitMenuDefinition( void model.runQuickAction(); }, }, + { + id: "git-version-control", + title: "Version Control", + icon: "point.topleft.down.curvedto.point.bottomright.up", + disabled: !model.isRepo, + subtitle: "Actionable branches, stashes, and remotes", + onPress: model.openVersionControl, + }, { id: "git-review", title: "Review changes", diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index d16b72d13d81..040e85889cf8 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -205,8 +205,8 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { accessibilityState={{ selected: branch.current, disabled: busy || disabled }} disabled={busy || disabled} onPress={() => { - void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { - navigation.goBack(); + void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => { + if (result !== null) navigation.goBack(); }); }} > diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 831ef1145cec..90ea5fe1cd2c 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -279,6 +279,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ) : null} {Platform.OS !== "android" ? : null} + + navigation.navigate("VersionControl", { + environmentId: String(environmentId), + threadId: String(threadId), + }) + } + /> + {Platform.OS !== "android" ? : null} ; + readonly renderFile: (file: VcsPanelFileChange) => ReactNode; +}) { + const [files, setFiles] = useState(null); + const [error, setError] = useState(false); + const [attempt, setAttempt] = useState(0); + useEffect(() => { + if (!commit.filesDeferred) return; + let active = true; + void api + .commitFiles({ cwd, sha: commit.sha }, attempt > 0) + .then((result) => { + if (active) setFiles(result.files); + }) + .catch(() => { + if (active) setError(true); + }); + return () => { + active = false; + }; + }, [api, cwd, commit.sha, commit.filesDeferred, attempt]); + if (error) + return ( + { + setError(false); + setFiles(null); + setAttempt((value) => value + 1); + }} + > + Could not load files. Retry + + ); + const loaded = commit.filesDeferred ? files : commit.files; + if (!loaded) return Loading files...; + if (!loaded.length) return No changed files.; + return file.path} renderItem={renderFile} />; +} diff --git a/apps/mobile/src/features/version-control/VersionControlDiffRouteScreen.tsx b/apps/mobile/src/features/version-control/VersionControlDiffRouteScreen.tsx new file mode 100644 index 000000000000..fbeed299b058 --- /dev/null +++ b/apps/mobile/src/features/version-control/VersionControlDiffRouteScreen.tsx @@ -0,0 +1,171 @@ +import type { VcsPanelFileChange, VcsPanelFileDiffInput } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { useEffect, useMemo, useState } from "react"; +import { Platform, ScrollView, StyleSheet, Text as NativeText, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { EmptyState } from "../../components/EmptyState"; +import { ScreenHeader } from "../../components/ScreenHeader"; +import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; +import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { + getCachedNativeReviewDiffData, + NATIVE_REVIEW_DIFF_CONTENT_WIDTH, + type BuildNativeReviewDiffDataInput, +} from "../review/nativeReviewDiffAdapter"; +import { buildReviewParsedDiff } from "../review/reviewModel"; +import { useNativeReviewDiffBridge } from "../review/useNativeReviewDiffBridge"; +import { useVersionControlPanelApi } from "./useVersionControlPanelApi"; +import { renameOriginalPathForFile } from "./versionControlModel"; +import { retryInterruptedVersionControlRequest } from "./versionControlRequest"; + +const EMPTY_IDS: readonly string[] = []; +const EMPTY_COMMENTS: NonNullable = []; + +type VersionControlDiffRouteScreenProps = StaticScreenProps<{ + readonly environmentId: string; + readonly cwd: string; + readonly file: VcsPanelFileChange; + readonly source: NonNullable; +}>; + +type DiffState = + | { readonly status: "loading" } + | { readonly status: "loaded"; readonly patch: string } + | { readonly status: "error"; readonly message: string }; + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) return error.message; + return "The file diff could not be loaded."; +} + +function RawPatchFallback(props: { readonly patch: string; readonly reason?: string }) { + return ( + + {props.reason ? ( + {props.reason} + ) : null} + + + {props.patch || "No textual diff available."} + + + + ); +} + +export function VersionControlDiffRouteScreen(props: VersionControlDiffRouteScreenProps) { + const { cwd, file, source } = props.route.params; + const navigation = useNavigation(); + const environmentId = EnvironmentId.make(props.route.params.environmentId); + const api = useVersionControlPanelApi(environmentId); + const [state, setState] = useState({ status: "loading" }); + const { themeAppearance: scheme } = useAppearancePreferences(); + const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); + const originalPath = renameOriginalPathForFile(file); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + void retryInterruptedVersionControlRequest(() => + api.readFileDiff({ + cwd, + path: file.path, + ...(originalPath ? { originalPath } : {}), + source, + }), + ) + .then((result) => { + if (!cancelled) setState({ status: "loaded", patch: result.patch }); + }) + .catch((cause) => { + if (!cancelled) { + setState({ status: "error", message: errorMessage(cause) }); + } + }); + return () => { + cancelled = true; + }; + }, [api, cwd, file.path, originalPath, source]); + + const patch = state.status === "loaded" ? state.patch : ""; + const cacheKey = `${cwd}:${file.path}:${JSON.stringify(source)}`; + const parsedDiff = useMemo(() => buildReviewParsedDiff(patch, cacheKey), [cacheKey, patch]); + const nativeData = useMemo( + () => getCachedNativeReviewDiffData({ parsedDiff, comments: EMPTY_COMMENTS }), + [parsedDiff], + ); + const NativeReviewDiffView = resolveNativeReviewDiffView(); + const bridge = useNativeReviewDiffBridge({ + threadKey: "version-control", + sectionId: cacheKey, + diff: patch, + data: nativeData, + collapsedFileIds: EMPTY_IDS, + viewedFileIds: EMPTY_IDS, + selectedRowIds: EMPTY_IDS, + canHighlight: NativeReviewDiffView !== null && parsedDiff.kind === "files", + }); + + return ( + <> + navigation.goBack()} + backInSplitView={{ + accessibilityLabel: "Back to Version Control", + icon: "chevron.left", + separateBackground: true, + }} + /> + {state.status === "loading" ? ( + + + + ) : state.status === "error" ? ( + + + + ) : parsedDiff.kind === "files" && NativeReviewDiffView ? ( + + + + ) : ( + + )} + + ); +} diff --git a/apps/mobile/src/features/version-control/VersionControlList.tsx b/apps/mobile/src/features/version-control/VersionControlList.tsx new file mode 100644 index 000000000000..9641a94f610a --- /dev/null +++ b/apps/mobile/src/features/version-control/VersionControlList.tsx @@ -0,0 +1,31 @@ +import { LegendList, type LegendListRenderItemProps } from "@legendapp/list/react-native"; +import { Fragment, type ReactNode } from "react"; +import { View } from "react-native"; + +export function VersionControlList({ + items, + getKey, + renderItem, +}: { + readonly items: readonly T[]; + readonly getKey: (item: T) => string; + readonly renderItem: (item: T) => ReactNode; +}) { + if (items.length <= 40) + return items.map((item) => {renderItem(item)}); + return ( + + + data={items} + extraData={renderItem} + keyExtractor={getKey} + renderItem={({ item }: LegendListRenderItemProps) => renderItem(item)} + estimatedItemSize={56} + drawDistance={180} + nestedScrollEnabled + recycleItems={false} + maintainVisibleContentPosition + /> + + ); +} diff --git a/apps/mobile/src/features/version-control/VersionControlRouteComponents.tsx b/apps/mobile/src/features/version-control/VersionControlRouteComponents.tsx new file mode 100644 index 000000000000..1c887d06744c --- /dev/null +++ b/apps/mobile/src/features/version-control/VersionControlRouteComponents.tsx @@ -0,0 +1,321 @@ +import type { + VcsPanelCommitSummary, + VcsPanelFileChange, + VcsPanelSnapshotResult, +} from "@t3tools/contracts"; +import { Modal, Pressable, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { fileStatusLetter, panelChangeSets, selectedFileStats } from "./versionControlModel"; + +export function ActionButton(props: { + readonly label: string; + readonly icon: React.ComponentProps["name"]; + readonly disabled?: boolean; + readonly danger?: boolean; + readonly onPress: () => void; +}) { + return ( + + + + {props.label} + + + ); +} + +export interface PublishRequest { + readonly branchName: string; + readonly targetCwd: string; +} + +export function PublishRemoteDialog(props: { + readonly request: PublishRequest | null; + readonly remoteNames: readonly string[]; + readonly disabled: boolean; + readonly onCancel: () => void; + readonly onSelect: (remoteName: string) => void; +}) { + return ( + + {props.request ? ( + + + Publish branch + + Choose a remote for {props.request.branchName}. + + + {props.remoteNames.map((remoteName) => ( + + props.onSelect(remoteName)} + > + {remoteName} + + + ))} + + + Cancel + + + + ) : null} + + ); +} + +export function ChangeCounts(props: { readonly insertions: number; readonly deletions: number }) { + if (props.insertions === 0 && props.deletions === 0) return null; + return ( + + {props.insertions > 0 ? ( + +{props.insertions} + ) : null} + {props.deletions > 0 ? ( + -{props.deletions} + ) : null} + + ); +} + +export function SectionHeader(props: { + readonly title: string; + readonly subtitle?: string | null; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly action?: React.ReactNode; +}) { + return ( + + + + + {props.title} + + {props.subtitle ? ( + {props.subtitle} + ) : null} + + {props.action} + + ); +} + +export function FileRow(props: { + readonly file: VcsPanelFileChange; + readonly selected?: boolean; + readonly disabled?: boolean; + readonly onSelect?: () => void; + readonly onOpenDiff: () => void; +}) { + return ( + + + {props.onSelect ? ( + + + + ) : null} + + + {fileStatusLetter(props.file.status)} + + + + {props.file.path} + + {props.file.originalPath ? ( + + from {props.file.originalPath} + + ) : null} + + + + + + + ); +} + +export function BranchCommitRow(props: { + readonly commit: VcsPanelCommitSummary; + readonly direction: "ahead" | "behind"; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly children: React.ReactNode; +}) { + const stats = props.commit.fileStats ?? selectedFileStats(props.commit.files); + return ( + + + + + + {props.commit.message} + + + {props.direction === "ahead" ? "↑" : "↓"} + + + + + {props.commit.authorName ?? "Unknown author"} · {props.commit.shortSha} + + {props.commit.fileStats || !props.commit.filesDeferred ? ( + + ) : null} + + + + + {props.expanded ? ( + props.commit.filesDeferred || props.commit.files.length > 0 ? ( + props.children + ) : ( + + No changed files. + + ) + ) : null} + + ); +} + +export function CompactTag(props: { readonly label: string }) { + return ( + + {props.label} + + ); +} + +export function RepositorySummary(props: { readonly snapshot: VcsPanelSnapshotResult }) { + const status = props.snapshot.status; + const files = panelChangeSets(props.snapshot, "__summary__").find( + (changeSet) => changeSet.current, + )?.files; + const stats = selectedFileStats(files ?? []); + const fileCount = files?.length ?? 0; + return ( + + + {status.refName ?? "Detached HEAD"} + + + {status.aheadCount > 0 ? ( + ↑{status.aheadCount} + ) : null} + {status.behindCount > 0 ? ( + ↓{status.behindCount} + ) : null} + {!status.hasUpstream ? ( + No upstream + ) : null} + {status.hasWorkingTreeChanges ? ( + + + {fileCount} {fileCount === 1 ? "file" : "files"} + + + + ) : ( + Clean + )} + + + ); +} diff --git a/apps/mobile/src/features/version-control/VersionControlRouteScreen.tsx b/apps/mobile/src/features/version-control/VersionControlRouteScreen.tsx new file mode 100644 index 000000000000..bc22ec63ce82 --- /dev/null +++ b/apps/mobile/src/features/version-control/VersionControlRouteScreen.tsx @@ -0,0 +1,954 @@ +import { panelBranchDetailsFingerprint } from "@t3tools/shared/sourceControl"; +import type { + VcsPanelBranchDetails, + VcsPanelFileChange, + VcsPanelFileDiffInput, + VcsPanelStashDetails, + VcsPanelSnapshotResult, + VcsRef, + VcsStatusResult, +} from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { panelBranchOperationCwd, panelBranchSyncState } from "@t3tools/shared/sourceControl"; +import { useAtomValue } from "@effect/atom-react"; +import * as Duration from "effect/Duration"; +import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Alert } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { retainMobileBackgroundActivityScope } from "../../connection/background-activity-scopes"; +import { ScreenHeader } from "../../components/ScreenHeader"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; +import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { useThreadSelection } from "../../state/use-thread-selection"; +import { vcsEnvironment } from "../../state/vcs"; +import { + actionableLocalBranches, + applyWorkingTreeEnrichments, + beginVersionControlAction, + beginDetailRequest, + branchOwnsOperationCwd, + clearResolvedDetailError, + detailRequestIsCurrent, + discardableFiles, + discardPathGroups, + operationPaths, + panelChangeSets, + newlyInitializedCurrentChangeSetCwds, + reconcileSelectedPaths, + snapshotForCwd, + snapshotIsPendingForCwd, + snapshotRequestIsCurrent, + stashIdentityKey, + type VersionControlChangeSet, +} from "./versionControlModel"; +import { + VersionControlCommandInterrupted, + useVersionControlPanelApi, +} from "./useVersionControlPanelApi"; +import { + mergeVersionControlRefreshOptions, + retainPullRefreshIndicator, + retryInterruptedVersionControlRequest, + runAutomaticRemoteFetch, + type VersionControlRefreshOptions, + VERSION_CONTROL_CHECKOUT_ACTION_OPTIONS, +} from "./versionControlRequest"; +import type { PublishRequest } from "./VersionControlRouteComponents"; +import { VersionControlRouteView } from "./VersionControlRouteView"; + +type VersionControlRouteScreenProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; +}>; + +type FileDiffSource = NonNullable; + +interface FileDiffRequest { + readonly cwd: string; + readonly file: VcsPanelFileChange; + readonly source: FileDiffSource; +} + +interface VersionControlRefreshQueue { + readonly cwd: string | null; + readonly runId: number; + queued: VersionControlRefreshOptions | null; + promise: Promise; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) return error.message; + return "The Version Control operation failed."; +} + +export function useVersionControlRouteController(props: VersionControlRouteScreenProps) { + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const environmentId = EnvironmentId.make(props.route.params.environmentId); + const { selectedThread } = useThreadSelection(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + const gitActions = useSelectedThreadGitActions(); + const api = useVersionControlPanelApi(environmentId); + const serverSettings = + useAtomValue(serverEnvironment.settingsValueAtom(environmentId)) ?? DEFAULT_SERVER_SETTINGS; + const sourceControlAllRemotesFetchIntervalMs = Duration.toMillis( + resolveServerBackgroundActivitySettings(serverSettings).sourceControlAllRemotesFetchInterval, + ); + const statusQuery = useEnvironmentQuery( + selectedThreadCwd + ? vcsEnvironment.status({ + environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); + + const [scopedSnapshot, setScopedSnapshot] = useState<{ + readonly cwd: string; + readonly snapshot: VcsPanelSnapshotResult; + } | null>(null); + const snapshot = snapshotForCwd(scopedSnapshot, selectedThreadCwd); + const [loading, setLoading] = useState(true); + const [settledSnapshotCwd, setSettledSnapshotCwd] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [busyAction, setBusyAction] = useState(null); + const runningActionKeysRef = useRef(new Set()); + const [error, setError] = useState(null); + const [mutationError, setMutationError] = useState(null); + const [actionableExpanded, setActionableExpanded] = useState(true); + const [remotesExpanded, setRemotesExpanded] = useState(false); + const [expandedRows, setExpandedRows] = useState>( + () => new Set(selectedThreadCwd ? [`changes:${selectedThreadCwd}`] : []), + ); + const expandedRowsRef = useRef>(expandedRows); + const [selectedByCwd, setSelectedByCwd] = useState>>( + new Map(), + ); + const knownPathsByCwd = useRef(new Map>()); + const initializedChangeSetCwds = useRef(new Set()); + const [branchDetails, setBranchDetails] = useState>( + new Map(), + ); + const [stashDetails, setStashDetails] = useState>( + new Map(), + ); + const [detailErrors, setDetailErrors] = useState>(new Map()); + const [showAddRemote, setShowAddRemote] = useState(false); + const [remoteName, setRemoteName] = useState(""); + const [remoteUrl, setRemoteUrl] = useState(""); + const [publishRequest, setPublishRequest] = useState(null); + const snapshotRequestId = useRef(0); + const detailRequestIds = useRef(new Map()); + const selectedThreadCwdRef = useRef(selectedThreadCwd); + const snapshotRevision = useRef(0); + const detailsFingerprint = useRef(null); + const snapshotFingerprint = useRef(null); + const refreshQueue = useRef(null); + const refreshRunId = useRef(0); + const automaticFetchesInFlight = useRef(new Set()); + + useLayoutEffect(() => { + selectedThreadCwdRef.current = selectedThreadCwd; + }, [selectedThreadCwd]); + + useEffect(() => { + expandedRowsRef.current = expandedRows; + }, [expandedRows]); + + useEffect(() => { + if (!selectedThreadCwd) return; + return retainMobileBackgroundActivityScope(environmentId, { + type: "git-refs", + cwd: selectedThreadCwd, + }); + }, [environmentId, selectedThreadCwd]); + + const syncSelections = useCallback((nextSnapshot: VcsPanelSnapshotResult, cwd: string) => { + const changeSets = panelChangeSets(nextSnapshot, cwd); + const newlyInitializedCurrentCwds = newlyInitializedCurrentChangeSetCwds( + changeSets, + initializedChangeSetCwds.current, + ); + const previousKnownPaths = knownPathsByCwd.current; + const nextKnownPaths = new Map( + changeSets.map( + (changeSet) => [changeSet.cwd, new Set(changeSet.files.map((file) => file.path))] as const, + ), + ); + knownPathsByCwd.current = nextKnownPaths; + + setExpandedRows((current) => { + const next = new Set(current); + for (const changeSetCwd of newlyInitializedCurrentCwds) next.add(`changes:${changeSetCwd}`); + return next; + }); + setSelectedByCwd((current) => + reconcileSelectedPaths({ + changeSets, + previousKnownPaths, + selectedByCwd: current, + }), + ); + }, []); + + useEffect(() => { + if (snapshot && selectedThreadCwd) syncSelections(snapshot, selectedThreadCwd); + }, [snapshot, selectedThreadCwd, syncSelections]); + + const performSnapshotRefresh = useCallback( + async (requestCwd: string | null, options: VersionControlRefreshOptions) => { + if (requestCwd !== selectedThreadCwdRef.current) return; + const requestId = ++snapshotRequestId.current; + setRefreshing((current) => retainPullRefreshIndicator(current, options.pull === true)); + if (!requestCwd) { + if (requestId === snapshotRequestId.current) { + setSettledSnapshotCwd(null); + setLoading(false); + setRefreshing(false); + } + return; + } + setSettledSnapshotCwd((current) => (current === requestCwd ? null : current)); + try { + const rawSnapshot = await api.snapshot({ + cwd: requestCwd, + refresh: options.refresh ?? "full", + }); + if ( + !snapshotRequestIsCurrent( + requestId, + snapshotRequestId.current, + requestCwd, + selectedThreadCwdRef.current, + ) + ) { + return; + } + const nextFingerprint = `${requestCwd}\0${JSON.stringify(rawSnapshot)}`; + if (snapshotFingerprint.current !== nextFingerprint) { + snapshotFingerprint.current = nextFingerprint; + const nextDetailsFingerprint = `${requestCwd}\0${panelBranchDetailsFingerprint(rawSnapshot)}\0${JSON.stringify(rawSnapshot.stashes)}`; + if ( + detailsFingerprint.current !== nextDetailsFingerprint || + options.refresh !== "working-tree" + ) { + detailsFingerprint.current = nextDetailsFingerprint; + snapshotRevision.current += 1; + setBranchDetails(new Map()); + setStashDetails(new Map()); + setDetailErrors(new Map()); + setExpandedRows( + (current) => + new Set( + [...current].filter( + (key) => + !key.startsWith("branch:") && + !key.startsWith("fork:") && + !key.startsWith("commit:") && + !key.startsWith("stash:"), + ), + ), + ); + } + } + enrichmentRequested.current.clear(); + enrichmentPending.current.clear(); + setScopedSnapshot({ cwd: requestCwd, snapshot: rawSnapshot }); + setError(null); + } catch (cause) { + if ( + snapshotRequestIsCurrent( + requestId, + snapshotRequestId.current, + requestCwd, + selectedThreadCwdRef.current, + ) && + !(cause instanceof VersionControlCommandInterrupted) + ) { + setError(errorMessage(cause)); + } + } finally { + if ( + snapshotRequestIsCurrent( + requestId, + snapshotRequestId.current, + requestCwd, + selectedThreadCwdRef.current, + ) + ) { + setSettledSnapshotCwd(requestCwd); + setLoading(false); + setRefreshing(false); + } + } + }, + [api, syncSelections], + ); + + const enrichmentRequested = useRef(new Set()); + const enrichmentPending = useRef(new Map()); + const enrichmentRunning = useRef(false); + const enrichmentTimer = useRef | null>(null); + const enqueueFileEnrichment = useCallback( + (file: VcsPanelFileChange, cwd: string) => { + if (file.status !== "untracked" && file.status !== "deleted") return; + const key = JSON.stringify([cwd, file.path]); + if (enrichmentRequested.current.has(key)) return; + enrichmentRequested.current.add(key); + enrichmentPending.current.set(key, { cwd, path: file.path }); + const flush = async () => { + enrichmentTimer.current = null; + if (enrichmentRunning.current) return; + enrichmentRunning.current = true; + try { + while (enrichmentPending.current.size > 0) { + const entries = [...enrichmentPending.current.entries()].slice(0, 64); + const cwd = entries[0]![1].cwd; + const batch = entries.filter(([, entry]) => entry.cwd === cwd); + for (const [key] of batch) enrichmentPending.current.delete(key); + const requestId = snapshotRequestId.current; + const result = await api.enrichWorkingTreeFiles({ + cwd, + paths: batch.map(([, entry]) => entry.path), + }); + if (requestId !== snapshotRequestId.current) continue; + setScopedSnapshot((current) => { + if (!current || current.cwd !== selectedThreadCwdRef.current) return current; + return { + ...current, + snapshot: applyWorkingTreeEnrichments( + current.snapshot, + current.cwd, + new Map([[cwd, result]]), + ), + }; + }); + } + } catch { + // Metadata can retry on the next authoritative snapshot. + } finally { + enrichmentRunning.current = false; + } + }; + if (!enrichmentRunning.current && enrichmentTimer.current === null) { + enrichmentTimer.current = setTimeout(() => void flush(), 50); + } + }, + [api], + ); + useEffect(() => { + if (!snapshot || !selectedThreadCwd) return; + for (const changeSet of panelChangeSets(snapshot, selectedThreadCwd)) { + for (const file of changeSet.files) enqueueFileEnrichment(file, changeSet.cwd); + } + }, [snapshot, selectedThreadCwd, enqueueFileEnrichment]); + useEffect( + () => () => { + enrichmentPending.current.clear(); + if (enrichmentTimer.current !== null) clearTimeout(enrichmentTimer.current); + }, + [], + ); + + const refreshSnapshot = useCallback( + (options: VersionControlRefreshOptions = {}): Promise => { + const requestCwd = selectedThreadCwd; + if (requestCwd !== selectedThreadCwdRef.current) return Promise.resolve(); + const active = refreshQueue.current; + if (active?.cwd === requestCwd) { + if (options.pull === true) setRefreshing(true); + active.queued = mergeVersionControlRefreshOptions(active.queued, options); + return active.promise; + } + + const runId = ++refreshRunId.current; + const queue: VersionControlRefreshQueue = { + cwd: requestCwd, + runId, + queued: null, + promise: Promise.resolve(), + }; + queue.promise = (async () => { + let nextOptions: VersionControlRefreshOptions | null = options; + while (nextOptions !== null) { + await performSnapshotRefresh(requestCwd, nextOptions); + if (refreshQueue.current?.runId !== runId) return; + nextOptions = queue.queued; + queue.queued = null; + } + })().finally(() => { + if (refreshQueue.current?.runId === runId) refreshQueue.current = null; + }); + refreshQueue.current = queue; + return queue.promise; + }, + [performSnapshotRefresh, selectedThreadCwd], + ); + + const runAction = useCallback( + async (label: string, action: () => Promise) => { + if (!beginVersionControlAction(runningActionKeysRef.current, label)) return false; + setBusyAction(label); + setError(null); + setMutationError(null); + let succeeded = false; + let actionError: string | null = null; + try { + await action(); + succeeded = true; + } catch (cause) { + if (!(cause instanceof VersionControlCommandInterrupted)) actionError = errorMessage(cause); + } finally { + try { + await refreshSnapshot(); + statusQuery.refresh(); + if (actionError) setMutationError(actionError); + } finally { + runningActionKeysRef.current.delete(label); + setBusyAction((current) => (current === label ? null : current)); + } + } + return succeeded; + }, + [refreshSnapshot, statusQuery], + ); + + useFocusEffect( + useCallback(() => { + if (!selectedThreadCwd) return; + const cwd = selectedThreadCwd; + void refreshSnapshot({ refresh: "working-tree" }); + if (sourceControlAllRemotesFetchIntervalMs <= 0) return; + + const refreshAllRemotes = () => { + void runAutomaticRemoteFetch({ + cwd, + inFlightCwds: automaticFetchesInFlight.current, + fetch: () => api.fetchAllRemotes({ cwd }), + refresh: () => refreshSnapshot(), + }); + }; + refreshAllRemotes(); + const interval = setInterval(refreshAllRemotes, sourceControlAllRemotesFetchIntervalMs); + return () => clearInterval(interval); + }, [api, refreshSnapshot, selectedThreadCwd, sourceControlAllRemotesFetchIntervalMs]), + ); + + const statusFingerprint = statusQuery.data ? JSON.stringify(statusQuery.data) : null; + const lastStatusRefresh = useRef<{ + readonly data: VcsStatusResult; + readonly fingerprint: string; + } | null>(null); + useEffect(() => { + if (!statusQuery.data || !statusFingerprint) return; + const previous = lastStatusRefresh.current; + if (previous?.data === statusQuery.data && previous.fingerprint === statusFingerprint) return; + lastStatusRefresh.current = { + data: statusQuery.data, + fingerprint: statusFingerprint, + }; + if (previous) void refreshSnapshot({ refresh: "working-tree" }); + }, [refreshSnapshot, statusFingerprint, statusQuery.data]); + + const changeSets = useMemo( + () => (snapshot && selectedThreadCwd ? panelChangeSets(snapshot, selectedThreadCwd) : []), + [selectedThreadCwd, snapshot], + ); + const localBranches = useMemo( + () => (snapshot ? actionableLocalBranches(snapshot) : []), + [snapshot], + ); + const actionCount = + changeSets.length + + localBranches.length + + (snapshot?.actionableForkBranches.length ?? 0) + + (snapshot?.stashes.length ?? 0); + const busy = busyAction !== null; + const header = ( + navigation.goBack(), + }, + ]} + /> + ); + + const toggleExpanded = useCallback((key: string) => { + setExpandedRows((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + expandedRowsRef.current = next; + return next; + }); + }, []); + + const openFileDiff = useCallback( + (request: FileDiffRequest) => { + navigation.navigate("VersionControlDiff", { + environmentId: String(environmentId), + cwd: request.cwd, + file: request.file, + source: request.source, + }); + }, + [environmentId, navigation], + ); + + const toggleSelectedFile = useCallback((cwd: string, path: string) => { + setSelectedByCwd((current) => { + const next = new Map(current); + const selected = new Set(next.get(cwd) ?? []); + if (selected.has(path)) selected.delete(path); + else selected.add(path); + next.set(cwd, selected); + return next; + }); + }, []); + + const selectAllFiles = useCallback((changeSet: VersionControlChangeSet) => { + setSelectedByCwd((current) => { + const next = new Map(current); + const selected = next.get(changeSet.cwd) ?? new Set(); + next.set( + changeSet.cwd, + selected.size === changeSet.files.length + ? new Set() + : new Set(changeSet.files.map((file) => file.path)), + ); + return next; + }); + }, []); + + const selectedFiles = useCallback( + (changeSet: VersionControlChangeSet) => { + const selected = selectedByCwd.get(changeSet.cwd) ?? new Set(); + return changeSet.files.filter((file) => selected.has(file.path)); + }, + [selectedByCwd], + ); + + const commitSelected = useCallback( + (changeSet: VersionControlChangeSet) => { + const files = selectedFiles(changeSet); + const paths = operationPaths(files); + if (paths.length === 0) return; + void runAction("commit", async () => { + await api.commitStaged({ cwd: changeSet.cwd, paths }); + }); + }, + [api, runAction, selectedFiles], + ); + + const stashSelected = useCallback( + (changeSet: VersionControlChangeSet) => { + const files = selectedFiles(changeSet); + const paths = operationPaths(files); + if (paths.length === 0) return; + void runAction("stash", () => + api.createStash({ cwd: changeSet.cwd, paths, includeUntracked: true }), + ); + }, + [api, runAction, selectedFiles], + ); + + const discardSelected = useCallback( + (changeSet: VersionControlChangeSet) => { + const files = discardableFiles(selectedFiles(changeSet)); + const paths = discardPathGroups(files); + if (paths.staged.length === 0 && paths.unstaged.length === 0) return; + Alert.alert( + "Discard selected changes?", + `This permanently discards changes in ${files.length} selected file${files.length === 1 ? "" : "s"}.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Discard", + style: "destructive", + onPress: () => + void runAction("discard", async () => { + if (paths.unstaged.length > 0) { + await api.discardFiles({ + cwd: changeSet.cwd, + paths: paths.unstaged, + }); + } + if (paths.staged.length > 0) { + await api.discardFiles({ + cwd: changeSet.cwd, + paths: paths.staged, + staged: true, + }); + } + }), + }, + ], + ); + }, + [api, runAction, selectedFiles], + ); + + const loadBranchDetails = useCallback( + (branch: VcsRef, key: string, compareBaseRef?: string) => { + const wasExpanded = expandedRowsRef.current.has(key); + toggleExpanded(key); + if (!snapshot || branchDetails.has(key) || wasExpanded) return; + const previousDetailError = detailErrors.get(key) ?? null; + const revision = snapshotRevision.current; + const requestId = beginDetailRequest(detailRequestIds.current, key); + setDetailErrors((current) => { + if (!current.has(key)) return current; + const next = new Map(current); + next.delete(key); + return next; + }); + void retryInterruptedVersionControlRequest(() => + api.branchDetails({ + cwd: selectedThreadCwd ?? "", + branch, + defaultCompareRef: snapshot.defaultCompareRef, + ...(compareBaseRef ? { compareBaseRef } : {}), + }), + ) + .then((details) => { + if ( + revision !== snapshotRevision.current || + !detailRequestIsCurrent(detailRequestIds.current, key, requestId) + ) { + return; + } + setBranchDetails((current) => new Map(current).set(key, details)); + setDetailErrors((current) => { + if (!current.has(key)) return current; + const next = new Map(current); + next.delete(key); + return next; + }); + setError((current) => clearResolvedDetailError(current, previousDetailError)); + }) + .catch((cause) => { + if ( + revision === snapshotRevision.current && + detailRequestIsCurrent(detailRequestIds.current, key, requestId) && + !(cause instanceof VersionControlCommandInterrupted) + ) { + const message = errorMessage(cause); + setDetailErrors((current) => new Map(current).set(key, message)); + setError(message); + } + }); + }, + [api, branchDetails, detailErrors, selectedThreadCwd, snapshot, toggleExpanded], + ); + + const publishBranch = useCallback( + (branch: VcsRef, targetCwd: string) => { + if (!snapshot) return; + if (snapshot.remotes.length === 0) { + setError("Add a remote before publishing this branch."); + return; + } + if (snapshot.remotes.length > 1) { + setPublishRequest({ branchName: branch.name, targetCwd }); + return; + } + const remote = snapshot.remotes[0]; + if (!remote) return; + void runAction("publish", () => + api.pushBranch({ + cwd: targetCwd, + branchName: branch.name, + remoteName: remote.name, + }), + ); + }, + [api, runAction, snapshot], + ); + + const publishToRemote = useCallback( + (remoteName: string) => { + const request = publishRequest; + if (!request) return; + setPublishRequest(null); + void runAction("publish", () => + api.pushBranch({ + cwd: request.targetCwd, + branchName: request.branchName, + remoteName, + }), + ); + }, + [api, publishRequest, runAction], + ); + + const syncBranch = useCallback( + (branch: VcsRef) => { + if (!snapshot || !selectedThreadCwd) return; + const state = panelBranchSyncState(branch, snapshot); + const targetCwd = panelBranchOperationCwd(branch, selectedThreadCwd); + if (state === "publish") { + publishBranch(branch, targetCwd); + return; + } + if (state === "push") { + void runAction("push", () => api.pushBranch({ cwd: targetCwd, branchName: branch.name })); + return; + } + if (state === "pull") { + void runAction("pull", () => api.pullBranch({ cwd: targetCwd, branchName: branch.name })); + return; + } + if (state === "fetch") { + void runAction("fetch", () => api.fetchBranch({ cwd: targetCwd, branchName: branch.name })); + return; + } + const canMerge = branchOwnsOperationCwd(branch); + Alert.alert("Branch has diverged", "Choose how to synchronize this branch.", [ + { text: "Cancel", style: "cancel" }, + ...(canMerge + ? [ + { + text: "Pull & merge", + onPress: () => + void runAction("merge-sync", () => + api.pullBranch({ + cwd: targetCwd, + branchName: branch.name, + merge: true, + }), + ), + }, + ] + : []), + { + text: "More…", + onPress: () => + Alert.alert("Destructive sync", "These actions overwrite one side of the branch.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Force pull", + style: "destructive", + onPress: () => + void runAction("force-pull", () => + api.pullBranch({ + cwd: targetCwd, + branchName: branch.name, + force: true, + }), + ), + }, + { + text: "Force push", + style: "destructive", + onPress: () => + void runAction("force-push", () => + api.pushBranch({ + cwd: targetCwd, + branchName: branch.name, + force: true, + }), + ), + }, + ]), + }, + ]); + }, + [api, publishBranch, runAction, selectedThreadCwd, snapshot], + ); + + const switchBranch = useCallback( + (branch: VcsRef) => { + void runAction("switch", async () => { + await gitActions.onCheckoutSelectedThreadBranch( + branch.name, + VERSION_CONTROL_CHECKOUT_ACTION_OPTIONS, + ); + }); + }, + [gitActions, runAction], + ); + + const deleteBranch = useCallback( + (branch: VcsRef) => { + if (!selectedThreadCwd || branch.current || branch.worktreePath !== null) return; + Alert.alert( + "Delete branch?", + `Delete ${branch.isRemote ? "remote branch " : ""}${branch.name}?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => + void runAction("delete-branch", () => + api.deleteBranch({ + cwd: selectedThreadCwd, + branchName: branch.name, + ...(branch.isRemote && branch.remoteName + ? { remoteName: branch.remoteName } + : {}), + }), + ), + }, + ], + ); + }, + [api, runAction, selectedThreadCwd], + ); + + const mergeBranch = useCallback( + (refName: string) => { + if (!selectedThreadCwd) return; + Alert.alert("Merge branch?", `Merge ${refName} into the current branch?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Merge", + onPress: () => + void runAction("merge-branch", () => + api.mergeBranchIntoCurrent({ cwd: selectedThreadCwd, refName }), + ), + }, + ]); + }, + [api, runAction, selectedThreadCwd], + ); + + const rebaseBranch = useCallback( + (refName: string) => { + if (!selectedThreadCwd) return; + Alert.alert("Rebase branch?", `Rebase the current branch onto ${refName}?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Rebase", + onPress: () => + void runAction("rebase-branch", () => + api.rebaseCurrentOnto({ cwd: selectedThreadCwd, refName }), + ), + }, + ]); + }, + [api, runAction, selectedThreadCwd], + ); + + const loadStashDetails = useCallback( + (stash: VcsPanelSnapshotResult["stashes"][number]) => { + const detailsKey = stashIdentityKey(stash); + const key = `stash:${detailsKey}`; + const wasExpanded = expandedRowsRef.current.has(key); + toggleExpanded(key); + if (!selectedThreadCwd || stashDetails.has(detailsKey) || wasExpanded) return; + const previousDetailError = detailErrors.get(key) ?? null; + const revision = snapshotRevision.current; + const requestId = beginDetailRequest(detailRequestIds.current, key); + setDetailErrors((current) => { + if (!current.has(key)) return current; + const next = new Map(current); + next.delete(key); + return next; + }); + void retryInterruptedVersionControlRequest(() => + api.stashDetails({ cwd: selectedThreadCwd, stashRef: stash.refName }), + ) + .then((details) => { + if ( + revision !== snapshotRevision.current || + !detailRequestIsCurrent(detailRequestIds.current, key, requestId) + ) { + return; + } + setStashDetails((current) => new Map(current).set(detailsKey, details)); + setDetailErrors((current) => { + if (!current.has(key)) return current; + const next = new Map(current); + next.delete(key); + return next; + }); + setError((current) => clearResolvedDetailError(current, previousDetailError)); + }) + .catch((cause) => { + if ( + revision === snapshotRevision.current && + detailRequestIsCurrent(detailRequestIds.current, key, requestId) && + !(cause instanceof VersionControlCommandInterrupted) + ) { + const message = errorMessage(cause); + setDetailErrors((current) => new Map(current).set(key, message)); + setError(message); + } + }); + }, + [api, detailErrors, selectedThreadCwd, stashDetails, toggleExpanded], + ); + + return { + actionableExpanded, + actionCount, + api, + branchDetails, + busy, + busyAction, + changeSets, + enqueueFileEnrichment, + commitSelected, + deleteBranch, + detailErrors, + discardSelected, + error, + expandedRows, + header, + insets, + loadBranchDetails, + loadStashDetails, + loading: loading || snapshotIsPendingForCwd(snapshot, selectedThreadCwd, settledSnapshotCwd), + localBranches, + mergeBranch, + mutationError, + openFileDiff, + publishRequest, + publishToRemote, + rebaseBranch, + refreshing, + refreshSnapshot, + remoteName, + remotesExpanded, + remoteUrl, + runAction, + selectAllFiles, + selectedByCwd, + selectedFiles, + selectedThread, + selectedThreadCwd, + setActionableExpanded, + setError, + setMutationError, + setPublishRequest, + setRemoteName, + setRemotesExpanded, + setRemoteUrl, + setShowAddRemote, + showAddRemote, + snapshot, + stashDetails, + stashSelected, + switchBranch, + syncBranch, + toggleExpanded, + toggleSelectedFile, + }; +} + +export type VersionControlRouteController = ReturnType; + +export function VersionControlRouteScreen(props: VersionControlRouteScreenProps) { + return ; +} diff --git a/apps/mobile/src/features/version-control/VersionControlRouteView.tsx b/apps/mobile/src/features/version-control/VersionControlRouteView.tsx new file mode 100644 index 000000000000..d988d21944fe --- /dev/null +++ b/apps/mobile/src/features/version-control/VersionControlRouteView.tsx @@ -0,0 +1,1006 @@ +import { useEffect, type ComponentProps } from "react"; +import { THREAD_LIST_V2_MONO_FONT } from "../threads/thread-list-v2-row-appearance"; +import { presentThreadPr } from "../../state/thread-pr-presentation"; +import { VersionControlList } from "./VersionControlList"; +import { VersionControlCommitFiles } from "./VersionControlCommitFiles"; +import type { + VcsPanelCommitSummary, + VcsPanelFileChange, + VcsPanelFileDiffInput, + VcsRef, +} from "@t3tools/contracts"; +import { panelBranchSyncCounts, panelBranchSyncState } from "@t3tools/shared/sourceControl"; +import { Alert, Pressable, RefreshControl, ScrollView, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { EmptyState } from "../../components/EmptyState"; +import { SheetActionButton } from "../threads/git/gitSheetComponents"; +import { + branchSyncLabel, + discardableFiles, + localBranchForRemoteBranch, + relativeLabel, + selectedFileStats, + stashIdentityKey, + visibleRemoteBranches, + workingTreeDiffIsStaged, +} from "./versionControlModel"; +import { + ActionButton, + BranchCommitRow, + ChangeCounts, + CompactTag, + FileRow, + PublishRemoteDialog, + RepositorySummary, + SectionHeader, +} from "./VersionControlRouteComponents"; +import type { VersionControlRouteController } from "./VersionControlRouteScreen"; + +type FileDiffSource = NonNullable; +interface FileDiffRequest { + readonly cwd: string; + readonly file: VcsPanelFileChange; + readonly source: FileDiffSource; +} + +function WorkingFileRow({ + cwd, + onRendered, + ...props +}: ComponentProps & { + readonly cwd: string; + readonly onRendered: (file: VcsPanelFileChange, cwd: string) => void; +}) { + useEffect(() => { + onRendered(props.file, cwd); + }, [props.file, cwd, onRendered]); + return ; +} + +export function VersionControlRouteView({ + controller, +}: { + readonly controller: VersionControlRouteController; +}) { + const { + actionableExpanded, + actionCount, + api, + branchDetails, + busy, + busyAction, + changeSets, + enqueueFileEnrichment, + commitSelected, + deleteBranch, + detailErrors, + discardSelected, + error, + expandedRows, + header, + insets, + loadBranchDetails, + loadStashDetails, + loading, + localBranches, + mergeBranch, + mutationError, + openFileDiff, + publishRequest, + publishToRemote, + rebaseBranch, + refreshing, + refreshSnapshot, + remoteName, + remotesExpanded, + remoteUrl, + runAction, + selectAllFiles, + selectedByCwd, + selectedFiles, + selectedThread, + selectedThreadCwd, + setActionableExpanded, + setError, + setMutationError, + setPublishRequest, + setRemoteName, + setRemotesExpanded, + setRemoteUrl, + setShowAddRemote, + showAddRemote, + snapshot, + stashDetails, + stashSelected, + switchBranch, + syncBranch, + toggleExpanded, + toggleSelectedFile, + } = controller; + const renderBranchCommit = ( + commit: VcsPanelCommitSummary, + direction: "ahead" | "behind", + parentKey: string, + cwd: string, + ) => { + const commitKey = `commit:${parentKey}:${commit.sha}`; + const expanded = expandedRows.has(commitKey); + return ( + toggleExpanded(commitKey)} + > + { + const request: FileDiffRequest = { + cwd, + file, + source: { kind: "commit", sha: commit.sha }, + }; + return ( + openFileDiff(request)} + /> + ); + }} + /> + + ); + }; + + if (!selectedThread || !selectedThreadCwd) { + return ( + <> + {header} + + + + + ); + } + + if (loading && !snapshot) { + return ( + <> + {header} + + + Loading repository state… + + + + ); + } + + if (!snapshot) { + return ( + <> + {header} + + void refreshSnapshot()} + /> + + + ); + } + + return ( + <> + {header} + remote.name)} + disabled={busy} + onCancel={() => setPublishRequest(null)} + onSelect={publishToRemote} + /> + void refreshSnapshot({ pull: true })} + /> + } + > + + + {(mutationError ?? error) ? ( + + + {mutationError ?? error} + + { + setMutationError(null); + setError(null); + }} + > + Dismiss + + + ) : null} + + {busyAction ? ( + + {busyAction} + + Refreshing repository state when done… + + + ) : null} + + + setActionableExpanded((value) => !value)} + action={ + + void runAction("fetch-all", () => + api.fetchAllRemotes({ cwd: selectedThreadCwd, force: true }), + ) + } + /> + } + /> + {actionableExpanded ? ( + + {changeSets.map((changeSet) => { + const rowKey = `changes:${changeSet.cwd}`; + const expanded = expandedRows.has(rowKey); + const selected = selectedFiles(changeSet); + const discardable = discardableFiles(selected); + const stats = selectedFileStats(selected); + return ( + + toggleExpanded(rowKey)} + > + + + {changeSet.current ? "Working tree" : changeSet.branchName} + + + {selected.length} of {changeSet.files.length} files selected + {changeSet.current ? "" : ` · ${changeSet.cwd}`} + + + + + {expanded ? ( + <> + + selectAllFiles(changeSet)} + /> + commitSelected(changeSet)} + /> + stashSelected(changeSet)} + /> + discardSelected(changeSet)} + /> + + file.path} + renderItem={(file) => { + const diffRequest: FileDiffRequest = { + cwd: changeSet.cwd, + file, + source: { + kind: "working-tree", + staged: workingTreeDiffIsStaged(file), + }, + }; + return ( + toggleSelectedFile(changeSet.cwd, file.path)} + onOpenDiff={() => openFileDiff(diffRequest)} + /> + ); + }} + /> + + ) : null} + + ); + })} + + {localBranches.map((branch) => { + const key = `branch:${branch.name}`; + const details = branchDetails.get(key); + const state = panelBranchSyncState(branch, snapshot); + const counts = panelBranchSyncCounts(branch, snapshot); + const date = relativeLabel(branch.lastActivityAt); + return ( + + loadBranchDetails(branch, key)} + > + + + {branch.name} + + + {branch.current + ? "Current branch" + : branch.worktreePath + ? "Checked out" + : "Local branch"} + {date ? ` · ${date}` : ""} + + + {counts.aheadCount > 0 ? ( + + ↑{counts.aheadCount} + + ) : null} + {counts.behindCount > 0 ? ( + + ↓{counts.behindCount} + + ) : null} + + {expandedRows.has(key) ? ( + + + {!branch.current && !branch.worktreePath ? ( + switchBranch(branch)} + /> + ) : null} + syncBranch(branch)} + /> + {!branch.current ? ( + <> + mergeBranch(branch.name)} + /> + rebaseBranch(branch.name)} + /> + deleteBranch(branch)} + /> + + ) : null} + + {details ? ( + <> + + + vs.{" "} + {details.baseRef ?? snapshot.defaultCompareRef ?? "default branch"} + + + {details.aheadCommits.length} ahead · {details.behindCommits.length}{" "} + behind · {details.compareFiles.length} changed + + + {details.aheadCommits.map((commit) => + renderBranchCommit(commit, "ahead", key, selectedThreadCwd), + )} + {details.behindCommits.map((commit) => + renderBranchCommit(commit, "behind", key, selectedThreadCwd), + )} + {details.aheadCommits.length === 0 && + details.behindCommits.length === 0 ? ( + + No commits differ from the comparison branch. + + ) : null} + + ) : ( + + {detailErrors.has(key) + ? `Unable to load branch details: ${detailErrors.get(key)}` + : "Loading branch details…"} + + )} + + ) : null} + + ); + })} + + {snapshot.actionableForkBranches.map((fork) => { + const branch = snapshot.localBranches.find( + (candidate) => candidate.name === fork.localBranchName, + ); + if (!branch) return null; + const key = `fork:${fork.localBranchName}:${fork.remoteRefName}`; + const pr = fork.pr + ? presentThreadPr( + fork.pr, + snapshot.remotes.find((remote) => remote.name === fork.remoteName)?.provider, + ) + : null; + return ( + + loadBranchDetails(branch, key, fork.remoteRefName)} + > + + + {fork.localBranchName} + + + Behind {fork.remoteRefName} + + + {pr ? ( + + #{pr.label} + + ) : null} + + ↓{fork.behindCount} + + + {expandedRows.has(key) ? ( + + + + void runAction("fetch-fork", () => + api.fetchBranch({ + cwd: selectedThreadCwd, + branchName: fork.remoteRefName, + }), + ) + } + /> + + {branchDetails.get(key) ? ( + <> + {branchDetails + .get(key) + ?.aheadCommits.map((commit) => + renderBranchCommit(commit, "ahead", key, selectedThreadCwd), + )} + {branchDetails + .get(key) + ?.behindCommits.map((commit) => + renderBranchCommit(commit, "behind", key, selectedThreadCwd), + )} + + ) : ( + + {detailErrors.has(key) + ? `Unable to load comparison: ${detailErrors.get(key)}` + : "Loading comparison…"} + + )} + + ) : null} + + ); + })} + + {snapshot.stashes.map((stash) => { + const detailsKey = stashIdentityKey(stash); + const key = `stash:${detailsKey}`; + const details = stashDetails.get(detailsKey); + return ( + + loadStashDetails(stash)} + > + + + {stash.message} + + + {stash.refName} + {relativeLabel(stash.createdAt) + ? ` · ${relativeLabel(stash.createdAt)}` + : ""} + + + + {expandedRows.has(key) ? ( + + + + void runAction("apply-stash", () => + api.applyStash({ + cwd: selectedThreadCwd, + expectedSha: stash.sha, + stashRef: stash.refName, + }), + ) + } + /> + + void runAction("pop-stash", () => + api.popStash({ + cwd: selectedThreadCwd, + expectedSha: stash.sha, + stashRef: stash.refName, + }), + ) + } + /> + + Alert.alert("Drop stash?", `Permanently drop ${stash.refName}?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Drop", + style: "destructive", + onPress: () => + void runAction("drop-stash", () => + api.dropStash({ + cwd: selectedThreadCwd, + expectedSha: stash.sha, + stashRef: stash.refName, + }), + ), + }, + ]) + } + /> + + {details ? ( + file.path} + renderItem={(file) => { + const request: FileDiffRequest = { + cwd: selectedThreadCwd, + file, + source: { + kind: "stash", + stashRef: stash.refName, + }, + }; + return ( + openFileDiff(request)} + /> + ); + }} + /> + ) : ( + + {detailErrors.has(key) + ? `Unable to load stash details: ${detailErrors.get(key)}` + : "Loading stash details…"} + + )} + + ) : null} + + ); + })} + + {actionCount === 0 ? ( + + + Nothing needs attention + + + The working tree and tracked branches are synchronized. + + + ) : null} + + ) : null} + + + + setRemotesExpanded((value) => !value)} + action={ + { + setRemotesExpanded(true); + setShowAddRemote((value) => !value); + }} + /> + } + /> + {remotesExpanded ? ( + + {showAddRemote ? ( + + + + + void runAction("add-remote", () => + api.addRemote({ + cwd: selectedThreadCwd, + name: remoteName.trim(), + url: remoteUrl.trim(), + }), + ).then((succeeded) => { + if (!succeeded) return; + setRemoteName(""); + setRemoteUrl(""); + setShowAddRemote(false); + }) + } + /> + + ) : null} + {snapshot.remotes.map((remote) => { + const key = `remote:${remote.name}`; + const remoteExpanded = expandedRows.has(key); + return ( + + toggleExpanded(key)} + > + + + + + {remote.name} + + + + {remote.fetchUrl ?? "No fetch URL"} · {remote.branches.length} branches + + + + {remoteExpanded ? ( + + + void runAction("fetch-remote", () => + api.fetchRemote({ + cwd: selectedThreadCwd, + remoteName: remote.name, + }), + ) + } + /> + + Alert.alert( + "Remove remote?", + `Remove ${remote.name} from this repository?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => + void runAction("remove-remote", () => + api.removeRemote({ + cwd: selectedThreadCwd, + remoteName: remote.name, + }), + ), + }, + ], + ) + } + /> + + ) : null} + {visibleRemoteBranches(remote, remoteExpanded).map((remoteBranch) => { + const localBranch = localBranchForRemoteBranch( + snapshot, + remote, + remoteBranch, + ); + const branch: VcsRef = localBranch ?? { + name: remoteBranch.fullRefName, + isRemote: true, + remoteName: remote.name, + current: false, + isDefault: remoteBranch.isDefaultRemoteHead, + worktreePath: null, + lastActivityAt: remoteBranch.lastActivityAt ?? null, + }; + const branchKey = `remote-branch:${remote.name}:${remoteBranch.name}`; + const branchExpanded = expandedRows.has(branchKey); + const counts = localBranch + ? panelBranchSyncCounts(localBranch, snapshot) + : { aheadCount: 0, behindCount: 0 }; + const syncState = localBranch + ? panelBranchSyncState(localBranch, snapshot) + : null; + return ( + + toggleExpanded(branchKey)} + > + + + + {remoteBranch.name} + + + + {localBranch?.current ? : null} + {localBranch?.worktreePath && !localBranch.current ? ( + + ) : null} + {remoteBranch.isDefaultRemoteHead || localBranch?.isDefault ? ( + + ) : null} + + + {counts.aheadCount > 0 ? ( + + ↑{counts.aheadCount} + + ) : null} + {counts.behindCount > 0 ? ( + + ↓{counts.behindCount} + + ) : null} + + {branchExpanded ? ( + + {!branch.current && !branch.worktreePath ? ( + switchBranch(branch)} + /> + ) : null} + {localBranch && syncState ? ( + syncBranch(localBranch)} + /> + ) : ( + + void runAction("fetch-remote-branch", () => + api.fetchBranch({ + cwd: selectedThreadCwd, + branchName: remoteBranch.fullRefName, + }), + ) + } + /> + )} + {!branch.current ? ( + <> + mergeBranch(branch.name)} + /> + rebaseBranch(branch.name)} + /> + deleteBranch(branch)} + /> + + ) : null} + + ) : null} + + ); + })} + + ); + })} + + ) : null} + + + + ); +} diff --git a/apps/mobile/src/features/version-control/useVersionControlPanelApi.test.ts b/apps/mobile/src/features/version-control/useVersionControlPanelApi.test.ts new file mode 100644 index 000000000000..39a27733dabe --- /dev/null +++ b/apps/mobile/src/features/version-control/useVersionControlPanelApi.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; +import { useVersionControlPanelApi } from "./useVersionControlPanelApi"; + +const mocks = vi.hoisted(() => ({ + report: vi.fn<() => Promise>(), + fetch: vi.fn(), +})); + +vi.mock("react", () => ({ + useCallback: (callback: T) => callback, + useMemo: (factory: () => T) => factory(), +})); +vi.mock("../../connection/background-activity", () => ({ + flushMobileBackgroundActivityReport: mocks.report, +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.fetch })); +vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => mocks.fetch })); +vi.mock("../../state/vcs", () => ({ vcsEnvironment: {} })); + +beforeEach(() => vi.resetAllMocks()); + +it("waits for current activity before fetching a newly focused route", async () => { + let acknowledge!: () => void; + let hasDemand = false; + mocks.report.mockImplementation( + () => + new Promise((resolve) => { + acknowledge = () => { + hasDemand = true; + resolve(); + }; + }), + ); + mocks.fetch.mockImplementation(async () => ({ _tag: "Success", value: hasDemand })); + const api = useVersionControlPanelApi(EnvironmentId.make("remote-environment")); + const fetching = api.fetchAllRemotes({ cwd: "/repo" }); + + expect(mocks.fetch).not.toHaveBeenCalled(); + acknowledge(); + await expect(fetching).resolves.toBe(true); +}); + +it("keeps explicit Fetch independent of activity reporting", async () => { + mocks.report.mockImplementation(() => new Promise(() => {})); + mocks.fetch.mockResolvedValue({ _tag: "Success", value: true }); + const api = useVersionControlPanelApi(EnvironmentId.make("remote-environment")); + + await expect(api.fetchAllRemotes({ cwd: "/repo", force: true })).resolves.toBe(true); + expect(mocks.report).not.toHaveBeenCalled(); +}); diff --git a/apps/mobile/src/features/version-control/useVersionControlPanelApi.ts b/apps/mobile/src/features/version-control/useVersionControlPanelApi.ts new file mode 100644 index 000000000000..bb9b627468bb --- /dev/null +++ b/apps/mobile/src/features/version-control/useVersionControlPanelApi.ts @@ -0,0 +1,217 @@ +import type { + EnvironmentId, + VcsPanelBranchDetails, + VcsPanelCommitFilesResult, + VcsPanelFileDiffResult, + VcsPanelSnapshotInput, + VcsPanelSnapshotResult, + VcsPanelStashDetails, + VcsPanelWorkingTreeFileEnrichmentResult, +} from "@t3tools/contracts"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useMemo } from "react"; +import { flushMobileBackgroundActivityReport } from "../../connection/background-activity"; + +import { useAtomCommand } from "../../state/use-atom-command"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { vcsEnvironment } from "../../state/vcs"; +import { VersionControlCommandInterrupted } from "./versionControlRequest"; + +export { VersionControlCommandInterrupted } from "./versionControlRequest"; + +async function unwrapPanelCommand( + result: AtomCommandResult, +): Promise { + if (result._tag === "Success") return result.value; + if (isAtomCommandInterrupted(result)) throw new VersionControlCommandInterrupted(); + throw squashAtomCommandFailure(result); +} + +export function useVersionControlPanelApi(environmentId: EnvironmentId) { + const panelSnapshot = useAtomQueryRunner(vcsEnvironment.panelSnapshot, { + refresh: true, + reportFailure: false, + }); + const retryPanelCommitFiles = useAtomQueryRunner(vcsEnvironment.panelCommitFiles, { + reportFailure: false, + refresh: true, + }); + const panelCommitFiles = useAtomQueryRunner(vcsEnvironment.panelCommitFiles, { + reportFailure: false, + }); + const panelBranchDetails = useAtomQueryRunner(vcsEnvironment.panelBranchDetails, { + refresh: true, + reportFailure: false, + }); + const panelStashDetails = useAtomQueryRunner(vcsEnvironment.panelStashDetails, { + refresh: true, + reportFailure: false, + }); + const panelReadFileDiff = useAtomQueryRunner(vcsEnvironment.panelReadFileDiff, { + refresh: true, + reportFailure: false, + }); + const panelEnrichWorkingTreeFiles = useAtomQueryRunner( + vcsEnvironment.panelEnrichWorkingTreeFiles, + { + refresh: true, + reportFailure: false, + }, + ); + + const panelStageFiles = useAtomCommand(vcsEnvironment.panelStageFiles, { + reportFailure: false, + }); + const panelDiscardFiles = useAtomCommand(vcsEnvironment.panelDiscardFiles, { + reportFailure: false, + }); + const panelCommitStaged = useAtomCommand(vcsEnvironment.panelCommitStaged, { + reportFailure: false, + }); + const panelPullBranch = useAtomCommand(vcsEnvironment.panelPullBranch, { + reportFailure: false, + }); + const panelPushBranch = useAtomCommand(vcsEnvironment.panelPushBranch, { + reportFailure: false, + }); + const panelDeleteBranch = useAtomCommand(vcsEnvironment.panelDeleteBranch, { + reportFailure: false, + }); + const panelMergeBranchIntoCurrent = useAtomCommand(vcsEnvironment.panelMergeBranchIntoCurrent, { + reportFailure: false, + }); + const panelRebaseCurrentOnto = useAtomCommand(vcsEnvironment.panelRebaseCurrentOnto, { + reportFailure: false, + }); + const panelFetchBranch = useAtomCommand(vcsEnvironment.panelFetchBranch, { + reportFailure: false, + }); + const panelFetchRemote = useAtomCommand(vcsEnvironment.panelFetchRemote, { + reportFailure: false, + }); + const panelFetchAllRemotes = useAtomCommand(vcsEnvironment.panelFetchAllRemotes, { + reportFailure: false, + }); + const panelAddRemote = useAtomCommand(vcsEnvironment.panelAddRemote, { + reportFailure: false, + }); + const panelRemoveRemote = useAtomCommand(vcsEnvironment.panelRemoveRemote, { + reportFailure: false, + }); + const panelCreateStash = useAtomCommand(vcsEnvironment.panelCreateStash, { + reportFailure: false, + }); + const panelApplyStash = useAtomCommand(vcsEnvironment.panelApplyStash, { + reportFailure: false, + }); + const panelPopStash = useAtomCommand(vcsEnvironment.panelPopStash, { + reportFailure: false, + }); + const panelDropStash = useAtomCommand(vcsEnvironment.panelDropStash, { + reportFailure: false, + }); + + const runPanelCommand = useCallback( + async ( + command: (target: { + readonly environmentId: EnvironmentId; + readonly input: TInput; + }) => Promise>, + input: TInput, + ): Promise => unwrapPanelCommand(await command({ environmentId, input })), + [environmentId], + ); + + return useMemo( + () => ({ + snapshot: (input: VcsPanelSnapshotInput) => + runPanelCommand(panelSnapshot, input), + commitFiles: (input: Parameters[0]["input"], retry = false) => + runPanelCommand( + retry ? retryPanelCommitFiles : panelCommitFiles, + input, + ), + branchDetails: (input: Parameters[0]["input"]) => + runPanelCommand(panelBranchDetails, { + ...input, + deferCommitFiles: true, + }), + stashDetails: (input: Parameters[0]["input"]) => + runPanelCommand(panelStashDetails, input), + readFileDiff: (input: Parameters[0]["input"]) => + runPanelCommand(panelReadFileDiff, input), + enrichWorkingTreeFiles: (input: Parameters[0]["input"]) => + runPanelCommand( + panelEnrichWorkingTreeFiles, + input, + ), + stageFiles: (input: Parameters[0]["input"]) => + runPanelCommand(panelStageFiles, input), + discardFiles: (input: Parameters[0]["input"]) => + runPanelCommand(panelDiscardFiles, input), + commitStaged: (input: Parameters[0]["input"]) => + runPanelCommand(panelCommitStaged, input), + pullBranch: (input: Parameters[0]["input"]) => + runPanelCommand(panelPullBranch, input), + pushBranch: (input: Parameters[0]["input"]) => + runPanelCommand(panelPushBranch, input), + deleteBranch: (input: Parameters[0]["input"]) => + runPanelCommand(panelDeleteBranch, input), + mergeBranchIntoCurrent: (input: Parameters[0]["input"]) => + runPanelCommand(panelMergeBranchIntoCurrent, input), + rebaseCurrentOnto: (input: Parameters[0]["input"]) => + runPanelCommand(panelRebaseCurrentOnto, input), + fetchBranch: (input: Parameters[0]["input"]) => + runPanelCommand(panelFetchBranch, input), + fetchRemote: (input: Parameters[0]["input"]) => + runPanelCommand(panelFetchRemote, input), + fetchAllRemotes: async (input: Parameters[0]["input"]) => { + if (input.force !== true) await flushMobileBackgroundActivityReport(); + return runPanelCommand(panelFetchAllRemotes, input); + }, + addRemote: (input: Parameters[0]["input"]) => + runPanelCommand(panelAddRemote, input), + removeRemote: (input: Parameters[0]["input"]) => + runPanelCommand(panelRemoveRemote, input), + createStash: (input: Parameters[0]["input"]) => + runPanelCommand(panelCreateStash, input), + applyStash: (input: Parameters[0]["input"]) => + runPanelCommand(panelApplyStash, input), + popStash: (input: Parameters[0]["input"]) => + runPanelCommand(panelPopStash, input), + dropStash: (input: Parameters[0]["input"]) => + runPanelCommand(panelDropStash, input), + }), + [ + panelAddRemote, + panelApplyStash, + panelCommitFiles, + retryPanelCommitFiles, + panelBranchDetails, + panelCommitStaged, + panelCreateStash, + panelDeleteBranch, + panelDiscardFiles, + panelDropStash, + panelEnrichWorkingTreeFiles, + panelFetchAllRemotes, + panelFetchBranch, + panelFetchRemote, + panelMergeBranchIntoCurrent, + panelPopStash, + panelPullBranch, + panelPushBranch, + panelReadFileDiff, + panelRebaseCurrentOnto, + panelRemoveRemote, + panelSnapshot, + panelStageFiles, + panelStashDetails, + runPanelCommand, + ], + ); +} diff --git a/apps/mobile/src/features/version-control/versionControlModel.test.ts b/apps/mobile/src/features/version-control/versionControlModel.test.ts new file mode 100644 index 000000000000..a2c558eb29cb --- /dev/null +++ b/apps/mobile/src/features/version-control/versionControlModel.test.ts @@ -0,0 +1,468 @@ +import type { VcsPanelSnapshotResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + actionableLocalBranches, + applyWorkingTreeEnrichments, + beginVersionControlAction, + beginDetailRequest, + branchOwnsOperationCwd, + clearResolvedDetailError, + detailRequestIsCurrent, + discardableFiles, + discardPathGroups, + localBranchForRemoteBranch, + newlyInitializedCurrentChangeSetCwds, + operationPaths, + panelChangeSets, + reconcileSelectedPaths, + relativeLabel, + renameOriginalPathForFile, + selectedFileStats, + snapshotForCwd, + snapshotIsPendingForCwd, + snapshotRequestIsCurrent, + stashIdentityKey, + visibleRemoteBranches, + workingTreeDiffIsStaged, + workingTreeEnrichmentRequests, +} from "./versionControlModel"; + +function snapshot(): VcsPanelSnapshotResult { + return { + status: { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/mobile-vcs", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 2, + behindCount: 0, + pr: null, + }, + changeGroups: [ + { + kind: "staged", + files: [ + { + path: "src/a.ts", + originalPath: null, + status: "modified", + insertions: 2, + deletions: 1, + }, + ], + }, + { + kind: "unstaged", + files: [ + { + path: "src/a.ts", + originalPath: null, + status: "modified", + insertions: 3, + deletions: 4, + }, + ], + }, + ], + worktreeChangeSets: [], + localBranches: [ + { + name: "feature/mobile-vcs", + current: true, + isDefault: false, + worktreePath: null, + upstreamName: "origin/feature/mobile-vcs", + upstreamRemoteName: "origin", + }, + { + name: "local-only", + current: false, + isDefault: false, + worktreePath: null, + upstreamName: null, + }, + { + name: "synced", + current: false, + isDefault: false, + worktreePath: null, + upstreamName: "origin/synced", + upstreamRemoteName: "origin", + aheadCount: 0, + behindCount: 0, + }, + ], + branchDetails: [], + remotes: [{ name: "origin", fetchUrl: null, pushUrl: null, provider: null, branches: [] }], + actionableForkBranches: [], + stashes: [], + recentCommits: [], + defaultCompareRef: "main", + }; +} + +describe("native Version Control model", () => { + it("acquires mutation keys synchronously until the action releases them", () => { + const runningActionKeys = new Set(); + + expect(beginVersionControlAction(runningActionKeys, "push")).toBe(true); + expect(beginVersionControlAction(runningActionKeys, "push")).toBe(false); + expect(beginVersionControlAction(runningActionKeys, "stash")).toBe(true); + + runningActionKeys.delete("push"); + expect(beginVersionControlAction(runningActionKeys, "push")).toBe(true); + }); + + it("merges the current working tree and preserves aggregate selected stats", () => { + const [changeSet] = panelChangeSets(snapshot(), "/repo"); + expect(changeSet?.files).toEqual([ + expect.objectContaining({ + path: "src/a.ts", + insertions: 5, + deletions: 5, + hasStagedChanges: true, + hasUnstagedChanges: true, + }), + ]); + expect(selectedFileStats(changeSet?.files ?? [])).toEqual({ insertions: 5, deletions: 5 }); + }); + + it("shows only branches that need action", () => { + expect(actionableLocalBranches(snapshot()).map((branch) => branch.name)).toEqual([ + "feature/mobile-vcs", + "local-only", + ]); + }); + + it("keeps stash identity stable when positional refs are renumbered", () => { + expect(stashIdentityKey({ refName: "stash@{2}", sha: "abc123" })).toBe("sha:abc123"); + expect(stashIdentityKey({ refName: "stash@{0}", sha: "abc123" })).toBe("sha:abc123"); + expect(stashIdentityKey({ refName: "stash@{0}", sha: null })).toBe("ref:stash@{0}"); + }); + + it("omits missing and invalid relative dates", () => { + expect(relativeLabel(null)).toBeNull(); + expect(relativeLabel("")).toBeNull(); + expect(relativeLabel("not-a-timestamp")).toBeNull(); + expect(relativeLabel(new Date().toISOString())).toBe("<1m"); + }); + + it("matches remote rows only to locals tracking that exact remote ref", () => { + const current = snapshot(); + const localBranches = [ + { + name: "release", + current: false, + isDefault: false, + worktreePath: null, + upstreamName: "upstream/release", + upstreamRemoteName: "upstream", + }, + { + name: "release", + current: false, + isDefault: false, + worktreePath: null, + upstreamName: "origin/release", + upstreamRemoteName: "origin", + }, + { + name: "untracked", + current: false, + isDefault: false, + worktreePath: null, + upstreamName: null, + }, + ]; + const remoteBranch = { + name: "release", + fullRefName: "origin/release", + isDefaultRemoteHead: false, + }; + + expect( + localBranchForRemoteBranch({ ...current, localBranches }, { name: "origin" }, remoteBranch), + ).toBe(localBranches[1]); + expect( + localBranchForRemoteBranch( + { ...current, localBranches: [localBranches[0]!] }, + { name: "origin" }, + remoteBranch, + ), + ).toBeNull(); + }); + + it.each([".", undefined])( + "does not pair a local or unknown upstream (%s) with a remote", + (upstreamRemoteName) => { + const current = snapshot(); + const local = { + ...current.localBranches[0]!, + name: "main", + upstreamName: "origin/main", + upstreamRemoteName, + }; + const tracker = { ...local, name: "tracker", upstreamRemoteName: "origin" }; + const remoteBranch = { name: "main", fullRefName: "origin/main", isDefaultRemoteHead: false }; + expect( + localBranchForRemoteBranch( + { ...current, localBranches: [local] }, + { name: "origin" }, + remoteBranch, + ), + ).toBeNull(); + expect( + localBranchForRemoteBranch( + { ...current, localBranches: [local, tracker] }, + { name: "origin" }, + remoteBranch, + ), + ).toBe(tracker); + }, + ); + + it("shows a remote's branches only while that remote is expanded", () => { + const remote = { + name: "origin", + fetchUrl: null, + pushUrl: null, + provider: null, + branches: [ + { + name: "main", + fullRefName: "origin/main", + isDefaultRemoteHead: true, + }, + ], + }; + + expect(visibleRemoteBranches(remote, false)).toEqual([]); + expect(visibleRemoteBranches(remote, true)).toBe(remote.branches); + }); + + it("includes both rename sides once in operation paths", () => { + expect( + operationPaths([ + { path: "new.ts", originalPath: "old.ts", status: "renamed" }, + { path: "new.ts", originalPath: "old.ts", status: "renamed" }, + ]), + ).toEqual(["new.ts", "old.ts"]); + }); + + it("does not mutate a copied file's live source path", () => { + expect( + operationPaths([{ path: "copy.ts", originalPath: "source.ts", status: "copied" }]), + ).toEqual(["copy.ts"]); + }); + + it("passes an original path to diff requests only for renames", () => { + expect( + renameOriginalPathForFile({ + originalPath: "old.ts", + status: "renamed", + }), + ).toBe("old.ts"); + expect( + renameOriginalPathForFile({ + originalPath: "source.ts", + status: "copied", + }), + ).toBeUndefined(); + }); + + it("exposes snapshots only to the cwd that loaded them", () => { + const current = snapshot(); + const scoped = { cwd: "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/repo/one", snapshot: current }; + + expect(snapshotForCwd(scoped, "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/repo/one")).toBe(current); + expect(snapshotForCwd(scoped, "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/repo/two")).toBeNull(); + }); + + it("stops treating a missing snapshot as pending after that cwd settles", () => { + expect(snapshotIsPendingForCwd(null, "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/repo/one", null)).toBe(true); + expect(snapshotIsPendingForCwd(null, "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/repo/one", "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/repo/two")).toBe(true); + expect(snapshotIsPendingForCwd(null, "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/repo/one", "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/repo/one")).toBe(false); + expect(snapshotIsPendingForCwd(snapshot(), "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/repo/one", null)).toBe(false); + expect(snapshotIsPendingForCwd(null, null, null)).toBe(false); + }); + + it("accepts a snapshot response only for the latest request and current cwd", () => { + expect(snapshotRequestIsCurrent(2, 2, "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/repo/one", "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/repo/one")).toBe(true); + expect(snapshotRequestIsCurrent(1, 2, "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/repo/one", "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/repo/one")).toBe(false); + expect(snapshotRequestIsCurrent(2, 2, "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/repo/one", "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/repo/two")).toBe(false); + }); + + it("accepts only the latest detail response for each expanded row", () => { + const requests = new Map(); + const first = beginDetailRequest(requests, "branch:feature"); + const second = beginDetailRequest(requests, "branch:feature"); + + expect(detailRequestIsCurrent(requests, "branch:feature", first)).toBe(false); + expect(detailRequestIsCurrent(requests, "branch:feature", second)).toBe(true); + }); + + it("clears only the shared banner for the resolved detail error", () => { + expect(clearResolvedDetailError("detail failed", "detail failed")).toBeNull(); + expect(clearResolvedDetailError("mutation failed", "detail failed")).toBe("mutation failed"); + expect(clearResolvedDetailError("detail failed", null)).toBe("detail failed"); + }); + + it("partitions mixed staged and unstaged files for complete discards", () => { + const files = panelChangeSets(snapshot(), "/repo")[0]?.files ?? []; + expect(discardPathGroups(files)).toEqual({ + staged: ["src/a.ts"], + unstaged: ["src/a.ts"], + }); + }); + + it("does not treat conflict-only files as unstaged discard targets", () => { + const conflictOnlyFile = { + path: "src/conflict.ts", + originalPath: null, + status: "conflicted" as const, + insertions: 0, + deletions: 0, + hasStagedChanges: false, + hasUnstagedChanges: false, + hasConflicts: true, + }; + expect(discardPathGroups([conflictOnlyFile])).toEqual({ staged: [], unstaged: [] }); + expect(discardableFiles([conflictOnlyFile])).toEqual([]); + }); + + it("opens only staged-only working-tree files from the staged side", () => { + expect( + workingTreeDiffIsStaged({ + hasStagedChanges: true, + hasUnstagedChanges: false, + }), + ).toBe(true); + expect( + workingTreeDiffIsStaged({ + hasStagedChanges: true, + hasUnstagedChanges: true, + }), + ).toBe(false); + expect( + workingTreeDiffIsStaged({ + hasStagedChanges: false, + hasUnstagedChanges: false, + }), + ).toBe(false); + }); + + it("keeps only files with discardable staged or unstaged changes", () => { + const files = panelChangeSets(snapshot(), "/repo")[0]?.files ?? []; + expect(discardableFiles(files)).toEqual(files); + }); + + it("selects new files and drops selection state for clean change sets", () => { + const [changeSet] = panelChangeSets(snapshot(), "/repo"); + if (!changeSet) throw new Error("Expected a current working-tree fixture"); + const next = reconcileSelectedPaths({ + changeSets: [ + { + ...changeSet, + files: [ + ...changeSet.files, + { + path: "src/new.ts", + originalPath: null, + status: "untracked", + insertions: 1, + deletions: 0, + hasStagedChanges: false, + hasUnstagedChanges: true, + hasConflicts: false, + }, + ], + }, + ], + previousKnownPaths: new Map([ + ["/repo", new Set(["src/a.ts"])], + ["/clean", new Set(["src/done.ts"])], + ]), + selectedByCwd: new Map([ + ["/repo", new Set(["src/a.ts"])], + ["/clean", new Set(["src/done.ts"])], + ]), + }); + + expect([...next.entries()].map(([cwd, paths]) => [cwd, [...paths]])).toEqual([ + ["/repo", ["src/a.ts", "src/new.ts"]], + ]); + }); + + it("initializes expansion only when a working tree becomes current", () => { + const initializedCwds = new Set(); + const [current] = panelChangeSets(snapshot(), "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/repo/current"); + if (!current) throw new Error("Expected a current working-tree fixture"); + const sibling = { ...current, cwd: "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/repo/sibling", current: false }; + + expect(newlyInitializedCurrentChangeSetCwds([sibling], initializedCwds)).toEqual([]); + expect(initializedCwds.has(sibling.cwd)).toBe(false); + expect( + newlyInitializedCurrentChangeSetCwds([{ ...sibling, current: true }], initializedCwds), + ).toEqual([sibling.cwd]); + expect(newlyInitializedCurrentChangeSetCwds([current], initializedCwds)).toEqual([current.cwd]); + expect(newlyInitializedCurrentChangeSetCwds([current], initializedCwds)).toEqual([]); + }); + + it("applies cwd-scoped untracked file enrichment", () => { + const next: VcsPanelSnapshotResult = { + ...snapshot(), + changeGroups: [ + { + kind: "unstaged", + files: [ + { + path: "src/new.ts", + originalPath: null, + status: "untracked", + insertions: 0, + deletions: 0, + }, + ], + }, + ], + }; + + expect(workingTreeEnrichmentRequests(next, "/repo")).toEqual([ + { cwd: "/repo", paths: ["src/new.ts"] }, + ]); + const enriched = applyWorkingTreeEnrichments( + next, + "/repo", + new Map([ + [ + "/repo", + { + files: [ + { + path: "src/new.ts", + originalPath: null, + status: "untracked", + insertions: 12, + deletions: 0, + }, + ], + hiddenPaths: [], + }, + ], + ]), + ); + expect(panelChangeSets(enriched, "/repo")[0]?.files[0]?.insertions).toBe(12); + }); + + it("only offers merge sync when the target cwd owns the branch", () => { + const [current, localOnly] = snapshot().localBranches; + if (!current || !localOnly) throw new Error("Expected current and local-only branch fixtures"); + expect(branchOwnsOperationCwd(current)).toBe(true); + expect(branchOwnsOperationCwd(localOnly)).toBe(false); + expect(branchOwnsOperationCwd({ ...localOnly, worktreePath: "/repo-worktree" })).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/version-control/versionControlModel.ts b/apps/mobile/src/features/version-control/versionControlModel.ts new file mode 100644 index 000000000000..93da173da7a2 --- /dev/null +++ b/apps/mobile/src/features/version-control/versionControlModel.ts @@ -0,0 +1,345 @@ +import type { + VcsPanelChangeGroup, + VcsPanelFileChange, + VcsPanelRemote, + VcsPanelSnapshotResult, + VcsPanelStash, + VcsPanelWorkingTreeFileEnrichmentResult, + VcsPanelWorktreeChangeSet, + VcsRef, +} from "@t3tools/contracts"; +import { + mergePanelChangeGroups, + panelBranchHasUpstream, + panelBranchSyncCounts, + type PanelChangedFile, +} from "@t3tools/shared/sourceControl"; + +import { relativeTime } from "../../lib/time"; + +export interface VersionControlChangeSet { + readonly id: string; + readonly branchName: string; + readonly cwd: string; + readonly current: boolean; + readonly lastActivityAt?: string | null; + readonly files: readonly PanelChangedFile[]; +} + +function siblingChangeSet(changeSet: VcsPanelWorktreeChangeSet): VersionControlChangeSet { + return { + id: `worktree:${changeSet.worktreePath}`, + branchName: changeSet.branchName, + cwd: changeSet.worktreePath, + current: changeSet.current, + lastActivityAt: changeSet.lastActivityAt, + files: mergePanelChangeGroups(changeSet.changeGroups), + }; +} + +export function panelChangeSets( + snapshot: VcsPanelSnapshotResult, + cwd: string, +): VersionControlChangeSet[] { + const currentBranch = snapshot.status.refName ?? "Detached HEAD"; + const current: VersionControlChangeSet = { + id: `worktree:${cwd}`, + branchName: currentBranch, + cwd, + current: true, + files: mergePanelChangeGroups(snapshot.changeGroups), + }; + + return [current, ...snapshot.worktreeChangeSets.map(siblingChangeSet)].filter( + (changeSet, index, all) => + changeSet.files.length > 0 && + all.findIndex((candidate) => candidate.cwd === changeSet.cwd) === index, + ); +} + +export function reconcileSelectedPaths(input: { + readonly changeSets: readonly VersionControlChangeSet[]; + readonly previousKnownPaths: ReadonlyMap>; + readonly selectedByCwd: ReadonlyMap>; +}): ReadonlyMap> { + const next = new Map>(); + for (const changeSet of input.changeSets) { + const known = input.previousKnownPaths.get(changeSet.cwd) ?? new Set(); + const visible = new Set(changeSet.files.map((file) => file.path)); + const selected = new Set(input.selectedByCwd.get(changeSet.cwd) ?? []); + for (const path of visible) { + if (!known.has(path)) selected.add(path); + } + for (const path of selected) { + if (!visible.has(path)) selected.delete(path); + } + next.set(changeSet.cwd, selected); + } + return next; +} + +export function newlyInitializedCurrentChangeSetCwds( + changeSets: readonly VersionControlChangeSet[], + initializedCwds: Set, +): readonly string[] { + return changeSets.flatMap((changeSet) => { + if (!changeSet.current || initializedCwds.has(changeSet.cwd)) return []; + initializedCwds.add(changeSet.cwd); + return [changeSet.cwd]; + }); +} + +export function actionableLocalBranches(snapshot: VcsPanelSnapshotResult): VcsRef[] { + return snapshot.localBranches.filter((branch) => { + const { aheadCount, behindCount } = panelBranchSyncCounts(branch, snapshot); + return !panelBranchHasUpstream(branch, snapshot) || aheadCount > 0 || behindCount > 0; + }); +} + +export function stashIdentityKey(stash: Pick): string { + return stash.sha ? `sha:${stash.sha}` : `ref:${stash.refName}`; +} + +export function relativeLabel(value: string | null | undefined): string | null { + if (!value || Number.isNaN(Date.parse(value))) return null; + return relativeTime(value); +} + +export function localBranchForRemoteBranch( + snapshot: VcsPanelSnapshotResult, + remote: Pick, + branch: VcsPanelRemote["branches"][number], +): VcsRef | null { + return ( + snapshot.localBranches.find( + (localBranch) => + localBranch.upstreamRemoteName === remote.name && + localBranch.upstreamName === branch.fullRefName, + ) ?? + snapshot.localBranches.find( + (localBranch) => + localBranch.name === branch.name && + localBranch.upstreamRemoteName === remote.name && + localBranch.upstreamName === `${remote.name}/${branch.name}`, + ) ?? + null + ); +} + +export function visibleRemoteBranches( + remote: Pick, + expanded: boolean, +): VcsPanelRemote["branches"] { + return expanded ? remote.branches : []; +} + +export function renameOriginalPathForFile( + file: Pick, +): string | undefined { + return file.status === "renamed" && file.originalPath ? file.originalPath : undefined; +} + +export function operationPaths( + files: readonly Pick[], +) { + return [ + ...new Set( + files.flatMap((file) => { + const originalPath = renameOriginalPathForFile(file); + return originalPath ? [file.path, originalPath] : [file.path]; + }), + ), + ]; +} + +export interface CwdScopedSnapshot { + readonly cwd: string; + readonly snapshot: VcsPanelSnapshotResult; +} + +export function snapshotForCwd( + scopedSnapshot: CwdScopedSnapshot | null, + cwd: string | null | undefined, +): VcsPanelSnapshotResult | null { + return scopedSnapshot !== null && scopedSnapshot.cwd === cwd ? scopedSnapshot.snapshot : null; +} + +export function snapshotIsPendingForCwd( + snapshot: VcsPanelSnapshotResult | null, + cwd: string | null | undefined, + settledSnapshotCwd: string | null, +): boolean { + return cwd != null && snapshot === null && settledSnapshotCwd !== cwd; +} + +export function snapshotRequestIsCurrent( + requestId: number, + latestRequestId: number, + requestedCwd: string, + currentCwd: string | null | undefined, +): boolean { + return requestId === latestRequestId && requestedCwd === currentCwd; +} + +export function beginDetailRequest(requestsByKey: Map, key: string): number { + const requestId = (requestsByKey.get(key) ?? 0) + 1; + requestsByKey.set(key, requestId); + return requestId; +} + +export function detailRequestIsCurrent( + requestsByKey: ReadonlyMap, + key: string, + requestId: number, +): boolean { + return requestsByKey.get(key) === requestId; +} + +export function clearResolvedDetailError( + currentError: string | null, + resolvedDetailError: string | null, +): string | null { + return resolvedDetailError !== null && currentError === resolvedDetailError ? null : currentError; +} + +export function discardPathGroups(files: readonly PanelChangedFile[]): { + readonly staged: readonly string[]; + readonly unstaged: readonly string[]; +} { + return { + staged: operationPaths(files.filter((file) => file.hasStagedChanges)), + unstaged: operationPaths(files.filter((file) => file.hasUnstagedChanges)), + }; +} + +export function discardableFiles(files: readonly PanelChangedFile[]): readonly PanelChangedFile[] { + return files.filter((file) => file.hasStagedChanges || file.hasUnstagedChanges); +} + +export function workingTreeDiffIsStaged( + file: Pick, +): boolean { + return file.hasStagedChanges && !file.hasUnstagedChanges; +} + +export function workingTreeEnrichmentRequests( + snapshot: VcsPanelSnapshotResult, + cwd: string, +): ReadonlyArray<{ readonly cwd: string; readonly paths: readonly string[] }> { + return panelChangeSets(snapshot, cwd).flatMap((changeSet) => { + const paths = changeSet.files + .filter( + (file) => + file.hasUnstagedChanges && (file.status === "untracked" || file.status === "deleted"), + ) + .map((file) => file.path); + return paths.length > 0 ? [{ cwd: changeSet.cwd, paths }] : []; + }); +} + +function applyWorkingTreeEnrichment( + groups: readonly VcsPanelChangeGroup[], + enrichment: VcsPanelWorkingTreeFileEnrichmentResult | undefined, +): VcsPanelChangeGroup[] { + if (!enrichment) return groups.map((group) => ({ ...group, files: [...group.files] })); + + const enrichedByPath = new Map(enrichment.files.map((file) => [file.path, file])); + const hiddenPaths = new Set(enrichment.hiddenPaths); + return groups.map((group) => { + if (group.kind !== "unstaged") return { ...group, files: [...group.files] }; + const seenPaths = new Set(); + const files = group.files.flatMap((file) => { + if (hiddenPaths.has(file.path)) return []; + const enriched = enrichedByPath.get(file.path) ?? file; + seenPaths.add(enriched.path); + return [enriched]; + }); + for (const file of enrichment.files) { + if (!seenPaths.has(file.path) && !hiddenPaths.has(file.path)) files.push(file); + } + return { + ...group, + files: files.sort((left, right) => left.path.localeCompare(right.path)), + }; + }); +} + +export function applyWorkingTreeEnrichments( + snapshot: VcsPanelSnapshotResult, + cwd: string, + enrichments: ReadonlyMap, +): VcsPanelSnapshotResult { + return { + ...snapshot, + changeGroups: applyWorkingTreeEnrichment(snapshot.changeGroups, enrichments.get(cwd)), + worktreeChangeSets: snapshot.worktreeChangeSets.map((changeSet) => ({ + ...changeSet, + changeGroups: applyWorkingTreeEnrichment( + changeSet.changeGroups, + enrichments.get(changeSet.worktreePath), + ), + })), + }; +} + +export function branchOwnsOperationCwd(branch: VcsRef): boolean { + return branch.current || branch.worktreePath !== null; +} + +export function selectedFileStats( + files: readonly Pick[], +) { + return files.reduce( + (total, file) => ({ + insertions: total.insertions + file.insertions, + deletions: total.deletions + file.deletions, + }), + { insertions: 0, deletions: 0 }, + ); +} + +export function beginVersionControlAction( + runningActionKeys: Set, + actionKey: string, +): boolean { + if (runningActionKeys.has(actionKey)) return false; + runningActionKeys.add(actionKey); + return true; +} + +export function fileStatusLetter(status: VcsPanelFileChange["status"]): string { + switch (status) { + case "added": + case "untracked": + return "A"; + case "deleted": + return "D"; + case "renamed": + return "R"; + case "copied": + return "C"; + case "conflicted": + return "!"; + case "modified": + return "M"; + } +} + +export function branchSyncLabel(input: { + readonly state: "fetch" | "pull" | "push" | "publish" | "diverged"; + readonly busy: boolean; +}): string { + if (input.busy) return "Working…"; + switch (input.state) { + case "fetch": + return "Fetch"; + case "pull": + return "Pull"; + case "push": + return "Push"; + case "publish": + return "Publish"; + case "diverged": + return "Sync"; + } +} diff --git a/apps/mobile/src/features/version-control/versionControlRequest.test.ts b/apps/mobile/src/features/version-control/versionControlRequest.test.ts new file mode 100644 index 000000000000..da52ebe42d79 --- /dev/null +++ b/apps/mobile/src/features/version-control/versionControlRequest.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + mergeVersionControlRefreshOptions, + retainPullRefreshIndicator, + retryInterruptedVersionControlRequest, + runAutomaticRemoteFetch, + VERSION_CONTROL_CHECKOUT_ACTION_OPTIONS, + VersionControlCommandInterrupted, +} from "./versionControlRequest"; + +describe("native Version Control requests", () => { + it("retains pull-to-refresh feedback when a background refresh supersedes it", () => { + expect(retainPullRefreshIndicator(false, true)).toBe(true); + expect(retainPullRefreshIndicator(true, false)).toBe(true); + expect(retainPullRefreshIndicator(false, false)).toBe(false); + }); + + it("keeps checkout failures local to the Version Control mutation surface", () => { + expect(VERSION_CONTROL_CHECKOUT_ACTION_OPTIONS).toEqual({ + reportFailure: false, + throwOnFailure: true, + }); + }); + + it("releases a failed automatic fetch so a later interval can retry it", async () => { + const inFlightCwds = new Set(); + const fetch = vi.fn<() => Promise>().mockRejectedValueOnce(new Error("offline")); + const refresh = vi.fn<() => Promise>().mockResolvedValue(); + + await expect( + runAutomaticRemoteFetch({ cwd: "/repo", inFlightCwds, fetch, refresh }), + ).resolves.toBe(false); + expect(inFlightCwds.has("/repo")).toBe(false); + + fetch.mockResolvedValueOnce(true); + await expect( + runAutomaticRemoteFetch({ cwd: "/repo", inFlightCwds, fetch, refresh }), + ).resolves.toBe(true); + expect(inFlightCwds.has("/repo")).toBe(false); + expect(fetch).toHaveBeenCalledTimes(2); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("deduplicates in-flight automatic fetches and skips cached-fetch reconciliation", async () => { + const inFlightCwds = new Set(); + let resolveFetch: (() => void) | undefined; + const fetch = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = () => resolve(true); + }), + ); + const refresh = vi.fn<() => Promise>().mockResolvedValue(); + + const first = runAutomaticRemoteFetch({ cwd: "/repo", inFlightCwds, fetch, refresh }); + await expect( + runAutomaticRemoteFetch({ cwd: "/repo", inFlightCwds, fetch, refresh }), + ).resolves.toBe(false); + resolveFetch?.(); + await expect(first).resolves.toBe(true); + + fetch.mockResolvedValueOnce(false); + await expect( + runAutomaticRemoteFetch({ cwd: "/repo", inFlightCwds, fetch, refresh }), + ).resolves.toBe(false); + expect(fetch).toHaveBeenCalledTimes(2); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("promotes queued working-tree work to a full refresh", () => { + expect( + mergeVersionControlRefreshOptions( + { pull: true, refresh: "working-tree" }, + { refresh: "full" }, + ), + ).toEqual({ pull: true, refresh: "full" }); + expect(mergeVersionControlRefreshOptions(null, { refresh: "working-tree" })).toEqual({ + refresh: "working-tree", + }); + }); + + it("retries an interrupted request", async () => { + const request = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new VersionControlCommandInterrupted()) + .mockResolvedValueOnce("loaded"); + + await expect(retryInterruptedVersionControlRequest(request)).resolves.toBe("loaded"); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("does not retry other failures", async () => { + const error = new Error("failed"); + const request = vi.fn<() => Promise>().mockRejectedValue(error); + + await expect(retryInterruptedVersionControlRequest(request)).rejects.toBe(error); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("bounds repeated interruption retries", async () => { + const request = vi + .fn<() => Promise>() + .mockRejectedValue(new VersionControlCommandInterrupted()); + + await expect(retryInterruptedVersionControlRequest(request)).rejects.toBeInstanceOf( + VersionControlCommandInterrupted, + ); + expect(request).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/features/version-control/versionControlRequest.ts b/apps/mobile/src/features/version-control/versionControlRequest.ts new file mode 100644 index 000000000000..0e6f67970080 --- /dev/null +++ b/apps/mobile/src/features/version-control/versionControlRequest.ts @@ -0,0 +1,69 @@ +export class VersionControlCommandInterrupted extends Error { + constructor() { + super("The Version Control command was interrupted."); + this.name = "VersionControlCommandInterrupted"; + } +} + +export const VERSION_CONTROL_CHECKOUT_ACTION_OPTIONS = { + reportFailure: false, + throwOnFailure: true, +} as const; + +export function retainPullRefreshIndicator(current: boolean, pullRequest: boolean): boolean { + return current || pullRequest; +} + +export interface VersionControlRefreshOptions { + readonly pull?: boolean; + readonly refresh?: "full" | "working-tree"; +} + +export function mergeVersionControlRefreshOptions( + current: VersionControlRefreshOptions | null, + next: VersionControlRefreshOptions, +): VersionControlRefreshOptions { + return { + ...(current?.pull === true || next.pull === true ? { pull: true } : {}), + refresh: + next.refresh !== "working-tree" || (current !== null && current.refresh !== "working-tree") + ? "full" + : "working-tree", + }; +} + +export async function runAutomaticRemoteFetch(options: { + readonly cwd: string; + readonly inFlightCwds: Set; + readonly fetch: () => Promise; + readonly refresh: () => Promise; +}): Promise { + if (options.inFlightCwds.has(options.cwd)) return false; + options.inFlightCwds.add(options.cwd); + try { + const fetched = await options.fetch(); + if (fetched) await options.refresh(); + return fetched; + } catch { + return false; + } finally { + options.inFlightCwds.delete(options.cwd); + } +} + +export async function retryInterruptedVersionControlRequest( + request: () => Promise, + maxRetries = 1, +): Promise { + let retries = 0; + while (true) { + try { + return await request(); + } catch (cause) { + if (!(cause instanceof VersionControlCommandInterrupted) || retries >= maxRetries) { + throw cause; + } + retries += 1; + } + } +} diff --git a/apps/mobile/src/state/use-atom-command.ts b/apps/mobile/src/state/use-atom-command.ts index 37ce280e9f41..4906ef357e83 100644 --- a/apps/mobile/src/state/use-atom-command.ts +++ b/apps/mobile/src/state/use-atom-command.ts @@ -5,13 +5,14 @@ import { type AtomCommandResult, runAtomCommand, } from "@t3tools/client-runtime/state/runtime"; +import type * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; import { useCallback, useContext } from "react"; export function useAtomCommand( command: AtomCommand, options?: string | AtomCommandOptions, ): (value: W) => Promise> { - const registry = useContext(RegistryContext); + const registry = useContext(RegistryContext) as AtomRegistry.AtomRegistry; const label = typeof options === "string" ? options : (options?.label ?? command.label); const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); diff --git a/apps/mobile/src/state/use-atom-query-runner.ts b/apps/mobile/src/state/use-atom-query-runner.ts index 691b1f43cb87..ba89992d2842 100644 --- a/apps/mobile/src/state/use-atom-query-runner.ts +++ b/apps/mobile/src/state/use-atom-query-runner.ts @@ -1,17 +1,18 @@ import { RegistryContext } from "@effect/atom-react"; import { executeAtomQuery, - type AtomQueryOptions, type AtomCommandResult, + type AtomQueryOptions, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult, type Atom } from "effect/unstable/reactivity"; +import type * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; import { useCallback, useContext } from "react"; export function useAtomQueryRunner( family: (target: T) => Atom.Atom>, options?: string | AtomQueryOptions, ): (target: T) => Promise> { - const registry = useContext(RegistryContext); + const registry = useContext(RegistryContext) as AtomRegistry.AtomRegistry; const explicitLabel = typeof options === "string" ? options : options?.label; const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); @@ -27,6 +28,6 @@ export function useAtomQueryRunner( refresh, }); }, - [explicitLabel, family, registry, refresh, reportDefect, reportFailure], + [explicitLabel, family, refresh, registry, reportDefect, reportFailure], ); } diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index e66f690428e8..d9159c7bf91e 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -131,9 +131,16 @@ export function useSelectedThreadGitActions() { readonly project: EnvironmentProject; readonly cwd: string; }) => Promise>, - options?: { readonly managedExternally?: boolean }, + options?: { + readonly managedExternally?: boolean; + readonly reportFailure?: boolean; + readonly throwOnFailure?: boolean; + }, ): Promise => { if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) { + if (options?.throwOnFailure === true) { + throw new Error("No repository is available for this Git action."); + } return null; } @@ -141,7 +148,8 @@ export function useSelectedThreadGitActions() { environmentId: selectedThread.environmentId, cwd: selectedThreadCwd, }; - setPendingConnectionError(null); + const reportFailure = options?.reportFailure !== false; + if (reportFailure) setPendingConnectionError(null); const run = () => execute({ thread: selectedThread, @@ -153,10 +161,18 @@ export function useSelectedThreadGitActions() { ? await run() : await vcsActionManager.track(appAtomRegistry, target, { operation, label }, run); if (AsyncResult.isFailure(result)) { + if (Cause.hasInterruptsOnly(result.cause)) { + return null; + } const error = Cause.squash(result.cause); const message = error instanceof Error ? error.message : "Git action failed."; - setPendingConnectionError(message); - showGitActionResult({ type: "error", title: "Git action failed", description: message }); + if (reportFailure) { + setPendingConnectionError(message); + showGitActionResult({ type: "error", title: "Git action failed", description: message }); + } + if (options?.throwOnFailure === true) { + throw error; + } return null; } return result.value; @@ -194,8 +210,14 @@ export function useSelectedThreadGitActions() { ); const onCheckoutSelectedThreadBranch = useCallback( - async (branch: string) => { - await runSelectedThreadGitMutation( + async ( + branch: string, + options?: { + readonly reportFailure?: boolean; + readonly throwOnFailure?: boolean; + }, + ) => { + return await runSelectedThreadGitMutation( "switch_ref", "Switching branch", async ({ thread, cwd }) => { @@ -216,6 +238,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, + options, ); }, [ diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 27fde35b9e90..cbff578a2d20 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -131,6 +131,36 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelSnapshot]: AuthOrchestrationReadScope, + [WS_METHODS.vcsPanelBranchDetails]: AuthOrchestrationReadScope, + [WS_METHODS.vcsPanelCommitFiles]: AuthOrchestrationReadScope, + [WS_METHODS.vcsPanelBranchCommits]: AuthOrchestrationReadScope, + [WS_METHODS.vcsPanelStashDetails]: AuthOrchestrationReadScope, + [WS_METHODS.vcsPanelEnrichWorkingTreeFiles]: AuthOrchestrationReadScope, + [WS_METHODS.vcsPanelReadFileDiff]: AuthOrchestrationReadScope, + [WS_METHODS.vcsPanelCompare]: AuthOrchestrationReadScope, + [WS_METHODS.vcsPanelCommitStaged]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelStageFiles]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelUnstageFiles]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelDiscardFiles]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelPullBranch]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelPushBranch]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelDeleteBranch]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelUndoLatestCommit]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelRevertCommit]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelCheckoutCommit]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelCreateBranchFromCommit]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelMergeBranchIntoCurrent]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelRebaseCurrentOnto]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelFetchBranch]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelFetchRemote]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelFetchAllRemotes]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelAddRemote]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelRemoveRemote]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelCreateStash]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelApplyStash]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelPopStash]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPanelDropStash]: AuthOrchestrationOperateScope, [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope, [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope, [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/diagnostics/ErrorCause.ts b/apps/server/src/diagnostics/ErrorCause.ts new file mode 100644 index 000000000000..42a9543afc11 --- /dev/null +++ b/apps/server/src/diagnostics/ErrorCause.ts @@ -0,0 +1,82 @@ +const MAX_DIAGNOSTIC_TEXT_LENGTH = 2_000; + +export interface SanitizedErrorCause { + readonly tag?: string; + readonly name?: string; + readonly message?: string; + readonly detail?: string; + readonly code?: string; + readonly exitCode?: number; + readonly timeoutMs?: number; + readonly truncated?: boolean; +} + +type MutableSanitizedErrorCause = { + -readonly [Key in keyof SanitizedErrorCause]?: SanitizedErrorCause[Key]; +}; + +function truncateDiagnosticText(value: string): { + readonly text: string; + readonly truncated: boolean; +} { + if (value.length <= MAX_DIAGNOSTIC_TEXT_LENGTH) { + return { text: value, truncated: false }; + } + return { + text: `${value.slice(0, MAX_DIAGNOSTIC_TEXT_LENGTH)}\n\n[truncated]`, + truncated: true, + }; +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function numberField(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function addTextField( + output: MutableSanitizedErrorCause, + key: "tag" | "name" | "message" | "detail" | "code", + value: string | undefined, +) { + if (!value) return; + const truncated = truncateDiagnosticText(value); + output[key] = truncated.text; + if (truncated.truncated) output.truncated = true; +} + +export function sanitizeErrorCause(cause: unknown): SanitizedErrorCause { + if (typeof cause === "string") { + const output: MutableSanitizedErrorCause = {}; + addTextField(output, "message", cause); + return output; + } + + if (typeof cause === "object" && cause !== null) { + const record = cause as Record; + const output: MutableSanitizedErrorCause = {}; + addTextField(output, "tag", stringField(record, "_tag")); + addTextField(output, "name", cause instanceof Error ? cause.name : stringField(record, "name")); + addTextField( + output, + "message", + cause instanceof Error ? cause.message : stringField(record, "message"), + ); + addTextField(output, "detail", stringField(record, "detail")); + addTextField(output, "code", stringField(record, "code")); + + const exitCode = numberField(record, "exitCode"); + if (exitCode !== undefined) output.exitCode = exitCode; + + const timeoutMs = numberField(record, "timeoutMs"); + if (timeoutMs !== undefined) output.timeoutMs = timeoutMs; + + return Object.keys(output).length > 0 ? output : { tag: "Object" }; + } + + return { message: "Unknown error" }; +} diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 2fceb0c41aa2..a965a9963301 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -44,7 +44,10 @@ import { ForgejoPullRequestSchema, toForgejoChangeRequest, } from "../sourceControl/forgejoPullRequests.ts"; -import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; +import type { + SourceControlProvider, + SourceControlProviderContext, +} from "../sourceControl/SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; @@ -542,8 +545,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { args: [ "pr", "list", - "--head", - input.headSelector, + ...(input.headSelector ? ["--head", input.headSelector] : []), "--state", "open", "--limit", @@ -603,6 +605,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + getCommitAvatarUrl: () => Effect.succeed(null), createRepository: (input) => Effect.fail( new GitHubCli.GitHubCliCommandError({ @@ -659,6 +662,7 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; sourceControlProvider?: SourceControlProvider["Service"]; + sourceControlContext?: SourceControlProviderContext; textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; @@ -707,7 +711,21 @@ function makeManager(input?: { resolveLink: (input) => provider.resolveLink?.(input), get: () => Effect.succeed(provider), resolveHandle: () => Effect.succeed({ provider, context: null }), - resolve: () => Effect.succeed(provider), + resolve: () => + Effect.succeed( + input?.sourceControlContext + ? { + ...provider, + listChangeRequests: (request) => + provider.listChangeRequests({ + ...request, + ...(input.sourceControlContext + ? { context: input.sourceControlContext } + : {}), + }), + } + : provider, + ), discover: Effect.succeed([]), }), ), @@ -790,6 +808,48 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status can omit provider-backed PR lookup while retaining remote sync state", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/panel-status"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "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/pingdotgg/codething-mvp.git", + remoteDir, + ); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/panel-status"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 19, + title: "Panel PR", + url: "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/pingdotgg/codething-mvp/pull/19", + baseRefName: "main", + headRefName: "feature/panel-status", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }, { includePullRequest: false }); + + expect(status.hasUpstream).toBe(true); + expect(status.aheadCount).toBe(0); + expect(status.behindCount).toBe(0); + expect(status.pr).toBeNull(); + expect(ghCalls).toHaveLength(0); + }), + ); + it.effect("status trims PR metadata returned by gh before publishing it", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1248,6 +1308,58 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect( + "automatic branch PR discovery lets gh select the target instead of forcing origin", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/upstream-pr"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/upstream-pr"]); + const { manager, ghCalls } = yield* makeManager({ + sourceControlContext: { + provider: { kind: "github", name: "GitHub", baseUrl: "https://github.com" }, + remoteName: "origin", + remoteUrl: "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/my-fork/t3code.git", + }, + ghScenario: { + prListSequence: [ + encodeCliJson([ + { + number: 12712, + title: "PR in the upstream repository", + url: "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/pingdotgg/t3code/pull/12712", + baseRefName: "main", + headRefName: "feature/upstream-pr", + state: "OPEN", + updatedAt: "2026-09-28T12:00:00Z", + }, + ]), + ], + }, + }); + const first = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/upstream-pr", + }); + const second = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/upstream-pr", + }); + expect(first).toMatchObject({ + number: 12712, + repositoryKey: "github.com/pingdotgg/t3code", + }); + expect(second).toEqual(first); + const lookups = ghCalls.filter((call) => call.startsWith("pr list ")); + expect(lookups).toHaveLength(1); + expect(lookups[0]).toContain("--head feature/upstream-pr"); + expect(lookups[0]).not.toContain("--repo"); + }), + ); + it.effect("branch PR lookup uses the saved name after the local branch is deleted", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f57afeb2a419..5d18d1cb9505 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2,7 +2,6 @@ import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as ByteSize from "effect/ByteSize"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -29,11 +28,9 @@ import { type VcsStatusLocalResult, type VcsStatusRemoteResult, VcsStatusResult, - ModelSelection, type ProjectId, SourceControlProviderError, type SourceControlProviderKind, - type SourceControlWritingStyleSettings, type ThreadId, } from "@t3tools/contracts"; import { @@ -58,10 +55,10 @@ import { import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import { - conventionalCommitsTextGenerationPolicy, - customTextGenerationPolicy, - repositoryConventionsTextGenerationPolicy, -} from "../textGeneration/TextGenerationPresets.ts"; + makeSourceControlRepositoryInstructionReader, + makeSourceControlWritingPolicyResolver, + type SourceControlTextGenerationSettings, +} from "../textGeneration/SourceControlWriting.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; @@ -69,6 +66,7 @@ import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import { canonicalizeExistingPath } from "../utils/CanonicalPath.ts"; import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; import type { ChangeRequest } from "@t3tools/contracts"; @@ -84,6 +82,8 @@ export interface GitRunStackedActionOptions { export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOptions { /** Retry a cached missing PR without clearing known PRs or failed lookup backoff. */ readonly refreshMissingPullRequest?: boolean; + /** Skip provider-backed PR lookup when the caller only needs repository synchronization state. */ + readonly includePullRequest?: boolean; } export type GitBranchPullRequest = NonNullable & { @@ -93,16 +93,12 @@ export type GitBranchPullRequest = NonNullable & { readonly mergedAt?: string | null; }; -interface SourceControlTextGenerationSettings { - readonly modelSelection: ModelSelection; - readonly style: SourceControlWritingStyleSettings; -} - export class GitManager extends Context.Service< GitManager, { readonly status: ( input: VcsStatusInput, + options?: GitRemoteStatusOptions, ) => Effect.Effect; readonly localStatus: ( input: VcsStatusInput, @@ -132,7 +128,6 @@ export class GitManager extends Context.Service< } >()("t3/git/GitManager") {} -const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; @@ -704,6 +699,16 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); + const automaticPullRequestProvider = (cwd: string) => + sourceControlProvider(cwd).pipe( + // gh selects the PR target repository, including a fork's upstream or an + // explicit `gh repo set-default`. Binding origin would search the push fork. + Effect.flatMap((provider) => + provider.kind === "github" + ? sourceControlProviders.get("github") + : Effect.succeed(provider), + ), + ); const serverSettingsService = yield* ServerSettings.ServerSettingsService; // Optional: git actions also run from the CLI and tests without orchestration. const projectionQuery = yield* Effect.serviceOption( @@ -727,82 +732,17 @@ export const make = Effect.gen(function* () { ).pipe(Effect.orElseSucceed(() => Option.none())); return resolveProjectSettings(settings, Option.getOrNull(projectId)).settings; }); - const readRepositoryInstructions = (cwd: string, fileName: string) => - Effect.gen(function* () { - const root = yield* fileSystem.realPath(cwd); - const instructionPath = yield* fileSystem.realPath(path.join(root, fileName)); - if (!instructionPath.startsWith(`${root}${path.sep}`)) { - return ""; - } - const info = yield* fileSystem.stat(instructionPath); - if (info.type !== "File" || info.size > ByteSize.bytes(20_000)) { - return ""; - } - return (yield* fileSystem.readFileString(instructionPath)).trim(); - }).pipe(Effect.orElseSucceed(() => "")); - - const readRecentCommitSubjects = (cwd: string) => - gitCore - .execute({ - operation: "GitManager.readRecentCommitSubjects", - cwd, - args: ["log", "-n", "20", "--no-merges", "--pretty=format:%s"], - }) - .pipe( - Effect.map((result) => - result.stdout - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0), - ), - Effect.orElseSucceed(() => []), - ); - - const resolveStylePolicy = (cwd: string, settings: SourceControlTextGenerationSettings) => - Effect.gen(function* () { - switch (settings.style.mode) { - case "conventional_commits": - return conventionalCommitsTextGenerationPolicy; - case "custom": - return customTextGenerationPolicy( - settings.style.customInstructions - ? { - commitInstructions: settings.style.customInstructions, - changeRequestInstructions: settings.style.customInstructions, - } - : {}, - ); - case "repo_conventions": { - const subjects = yield* readRecentCommitSubjects(cwd); - const agentInstructions = yield* readRepositoryInstructions(cwd, "AGENTS.md"); - const isClaudeWriter = - settings.modelSelection.instanceId === "claudeAgent" || - (yield* providerRegistry.getProviders).some( - (provider) => - provider.instanceId === settings.modelSelection.instanceId && - provider.driver === "claudeAgent", - ); - const claudeInstructions = isClaudeWriter - ? yield* readRepositoryInstructions(cwd, "CLAUDE.md") - : ""; - const examples = [ - ...(subjects.length > 0 - ? [["Recent commit subjects from this repository:", ...subjects].join("\n")] - : []), - ...(agentInstructions ? [`Local AGENTS.md:\n${agentInstructions}`] : []), - ...(claudeInstructions ? [`Local CLAUDE.md:\n${claudeInstructions}`] : []), - ].join("\n\n"); - if (!examples) { - return repositoryConventionsTextGenerationPolicy; - } - return { - ...repositoryConventionsTextGenerationPolicy, - commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`, - changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`, - }; - } - } - }); + const resolveStylePolicy = makeSourceControlWritingPolicyResolver({ + runGit: (cwd, args) => + gitCore + .execute({ + operation: "GitManager.readRecentCommitSubjects", + cwd, + args, + }) + .pipe(Effect.map((result) => result.stdout)), + readRepositoryInstructions: makeSourceControlRepositoryInstructionReader(fileSystem, path), + }); const randomUUIDv4 = (cwd: string) => crypto.randomUUIDv4.pipe( Effect.mapError( @@ -997,9 +937,7 @@ export const make = Effect.gen(function* () { ), ); const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; - const canonicalizeExistingPath = (value: string) => - fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); - const normalizeStatusCacheKey = canonicalizeExistingPath; + const normalizeStatusCacheKey = (value: string) => canonicalizeExistingPath(fileSystem, value); const nonRepositoryStatusDetails = { isRepo: false, hasOriginRemote: false, @@ -1285,7 +1223,7 @@ export const make = Effect.gen(function* () { } const pr = - details.branch !== null + options?.includePullRequest !== false && details.branch !== null ? yield* lookupStatusPr( cwd, { @@ -1625,7 +1563,7 @@ export const make = Effect.gen(function* () { | "isCrossRepository" >, ) { - const provider = yield* sourceControlProvider(cwd); + const provider = yield* automaticPullRequestProvider(cwd); const headSelectors = probeableHeadSelectors(provider.kind, headContext.headSelectors); for (const headSelector of headSelectors) { const pullRequests = yield* provider.listChangeRequests({ @@ -1657,7 +1595,7 @@ export const make = Effect.gen(function* () { ) { const parsedByNumber = new Map(); - const provider = yield* sourceControlProvider(cwd); + const provider = yield* automaticPullRequestProvider(cwd); for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) { const pullRequests = yield* provider.listChangeRequests({ cwd, @@ -1982,7 +1920,6 @@ export const make = Effect.gen(function* () { } : null; const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { - timeoutMs: COMMIT_TIMEOUT_MS, ...(commitProgress ? { progress: commitProgress } : {}), }); if (currentHookName !== null) { @@ -2128,14 +2065,18 @@ export const make = Effect.gen(function* () { const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( function* (input, options) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); - if (options?.refreshUpstream === false || options?.refreshMissingPullRequest) { + if ( + options?.refreshUpstream === false || + options?.refreshMissingPullRequest || + options?.includePullRequest === false + ) { return yield* readRemoteStatus(cacheKey, options); } return yield* Cache.get(remoteStatusResultCache, cacheKey); }, ); - const status: GitManager["Service"]["status"] = Effect.fn("status")(function* (input) { - const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)], { + const status: GitManager["Service"]["status"] = Effect.fn("status")(function* (input, options) { + const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input, options)], { concurrency: "unbounded", }); return mergeGitStatusParts(local, remote); @@ -2352,7 +2293,7 @@ export const make = Effect.gen(function* () { }; return yield* Effect.gen(function* () { const normalizedReference = normalizePullRequestReference(input.reference); - const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd); + const rootWorktreePath = yield* canonicalizeExistingPath(fileSystem, input.cwd); const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({ cwd: input.cwd, reference: normalizedReference, @@ -2516,7 +2457,7 @@ export const make = Effect.gen(function* () { continue; } - const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath); + const worktreePath = yield* canonicalizeExistingPath(fileSystem, branch.worktreePath); if (worktreePath !== rootWorktreePath) { return branch; } @@ -2527,7 +2468,7 @@ export const make = Effect.gen(function* () { const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath - ? yield* canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + ? yield* canonicalizeExistingPath(fileSystem, existingBranchBeforeFetch.worktreePath) : null; if ( existingBranchBeforeFetch?.worktreePath && @@ -2555,7 +2496,7 @@ export const make = Effect.gen(function* () { const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath - ? yield* canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + ? yield* canonicalizeExistingPath(fileSystem, existingBranchAfterFetch.worktreePath) : null; if ( existingBranchAfterFetch?.worktreePath && @@ -2703,23 +2644,18 @@ export const make = Effect.gen(function* () { let commitMessageForStep = input.commitMessage; let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; - const textGenerationSettings = yield* projectSettingsFor(input).pipe( - Effect.flatMap((settings) => - settings.sourceControlWriterModelSelection === null - ? Effect.succeed({ - modelSelection: settings.textGenerationModelSelection, - style: settings.sourceControlWritingStyle, - }) - : providerRegistry.getProviders.pipe( - Effect.map((providers) => ({ - modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( - settings, - providers, - ), - style: settings.sourceControlWritingStyle, - })), - ), - ), + const textGenerationSettings = yield* Effect.all( + [projectSettingsFor(input), providerRegistry.getProviders], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([settings, providers]) => ({ + modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( + settings, + providers, + ), + providers, + style: settings.sourceControlWritingStyle, + })), Effect.mapError( (cause) => new GitManagerError({ diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index f5f5a6d39336..fc2d13599ea6 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -42,6 +42,7 @@ export class GitWorkflowService extends Context.Service< }) => Effect.Effect; readonly status: ( input: VcsStatusInput, + options?: GitManager.GitRemoteStatusOptions, ) => Effect.Effect; readonly localStatus: ( input: VcsStatusInput, @@ -295,10 +296,12 @@ export const make = Effect.gen(function* () { ), Effect.map((result) => result.exitCode === 0), ), - status: (input) => + status: (input, options) => detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( Effect.flatMap((isGitRepository) => - isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()), + isGitRepository + ? gitManager.status(input, options) + : Effect.succeed(nonRepositoryStatus()), ), ), localStatus: (input) => diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 221941aa7833..2174c3f4ce6b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -179,6 +179,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; +import * as SourceControlPanelService from "./sourceControl/SourceControlPanelService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import { REPLAY_MARKER_MAX_AGE } from "./auth/replayMarkers.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -543,6 +544,9 @@ const buildAppUnderTest = (options?: { sourceControlRepositoryService?: Partial< SourceControlRepositoryService.SourceControlRepositoryService["Service"] >; + sourceControlPanelService?: Partial< + SourceControlPanelService.SourceControlPanelService["Service"] + >; reviewService?: Partial; vcsStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial< @@ -1083,68 +1087,53 @@ const buildAppUnderTest = (options?: { ), ); - const appLayer = servedRoutesLayer.pipe( - Layer.provide(resourceTelemetryLayer), - Layer.provide(UsageService.layerTest), - Layer.provide( - Layer.mock(AnalyticsService.AnalyticsService)({ - record: () => Effect.void, - flush: Effect.void, - ...options?.layers?.analyticsService, - }), - ), - Layer.provide( - Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ - record: () => Effect.void, - ...options?.layers?.browserTraceCollector, - }), - ), - Layer.provide(otlpSerializationLayer(config.otlpTracesExport.protocol)), - Layer.provide( - Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({ - publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), - snapshot: Effect.succeed({ sequence: 0, events: [] }), - stream: Stream.empty, - ...options?.layers?.serverLifecycleEvents, - }), - ), - Layer.provide( - Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({ - awaitCommandReady: Effect.void, - markHttpListening: Effect.void, - markRunningProviderSessionsForContinuation: Effect.succeed([]), - clearProviderSessionContinuationMarkers: () => Effect.void, - enqueueCommand: (effect) => effect, - ...options?.layers?.serverRuntimeStartup, - }), - ), - Layer.provide( - Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - reportClientActivity: () => Effect.void, - removeRpcClient: () => Effect.void, - reportHostPowerState: () => Effect.void, - snapshot: Effect.succeed({ - hostPower: { - source: "unknown", - idle: "unknown", - idleSeconds: null, - locked: "unknown", - suspended: false, - onBattery: "unknown", - lowPowerMode: "unknown", - thermalState: "unknown", - stale: true, - updatedAt: TEST_EPOCH, - }, - leases: [], - activeForegroundLeaseCount: 0, - activeScopeKeys: [], - shouldRunOpportunisticWork: false, - updatedAt: TEST_EPOCH, + const appLayer = servedRoutesLayer + .pipe( + Layer.provide(resourceTelemetryLayer), + Layer.provide(UsageService.layerTest), + Layer.provide( + Layer.mock(SourceControlPanelService.SourceControlPanelService)({ + ...options?.layers?.sourceControlPanelService, }), - streamChanges: Stream.empty, - subscribe: Effect.succeed({ - latest: { + ), + Layer.provide( + Layer.mock(AnalyticsService.AnalyticsService)({ + record: () => Effect.void, + flush: Effect.void, + ...options?.layers?.analyticsService, + }), + ), + Layer.provide( + Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ + record: () => Effect.void, + ...options?.layers?.browserTraceCollector, + }), + ), + Layer.provide(otlpSerializationLayer(config.otlpTracesExport.protocol)), + Layer.provide( + Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({ + publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), + snapshot: Effect.succeed({ sequence: 0, events: [] }), + stream: Stream.empty, + ...options?.layers?.serverLifecycleEvents, + }), + ), + Layer.provide( + Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({ + awaitCommandReady: Effect.void, + markHttpListening: Effect.void, + markRunningProviderSessionsForContinuation: Effect.succeed([]), + clearProviderSessionContinuationMarkers: () => Effect.void, + enqueueCommand: (effect) => effect, + ...options?.layers?.serverRuntimeStartup, + }), + ), + Layer.provide( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + reportClientActivity: () => Effect.void, + removeRpcClient: () => Effect.void, + reportHostPowerState: () => Effect.void, + snapshot: Effect.succeed({ hostPower: { source: "unknown", idle: "unknown", @@ -1162,97 +1151,121 @@ const buildAppUnderTest = (options?: { activeScopeKeys: [], shouldRunOpportunisticWork: false, updatedAt: TEST_EPOCH, - }, - changes: Stream.empty, + }), + streamChanges: Stream.empty, + subscribe: Effect.succeed({ + latest: { + hostPower: { + source: "unknown", + idle: "unknown", + idleSeconds: null, + locked: "unknown", + suspended: false, + onBattery: "unknown", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: true, + updatedAt: TEST_EPOCH, + }, + leases: [], + activeForegroundLeaseCount: 0, + activeScopeKeys: [], + shouldRunOpportunisticWork: false, + updatedAt: TEST_EPOCH, + }, + changes: Stream.empty, + }), + hasDemand: () => Effect.succeed(false), + shouldRunScopeWork: () => Effect.succeed(false), + shouldRunOpportunisticWork: Effect.succeed(false), }), - hasDemand: () => Effect.succeed(false), - shouldRunScopeWork: () => Effect.succeed(false), - shouldRunOpportunisticWork: Effect.succeed(false), - }), - ), - Layer.provide( - Layer.mock(ServerEnvironment.ServerEnvironment)({ - getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), - getDescriptor: Effect.succeed(testEnvironmentDescriptor), - ...options?.layers?.serverEnvironment, - }), - ), - Layer.provide( - Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ - resolve: () => Effect.succeed(null), - ...options?.layers?.repositoryIdentityResolver, - }), - ), - Layer.provide( - Layer.mergeAll( - Layer.succeed( - CloudManagedEndpointRuntime.CloudManagedEndpointRuntime, - CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ - applyConfig: () => Effect.succeed({ status: "disabled" }), - recoveryRequests: Stream.empty, - requestRecovery: () => Effect.void, - withLinkStateLock: (effect) => effect, - ...options?.layers?.cloudManagedEndpointRuntime, + ), + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + getDescriptor: Effect.succeed(testEnvironmentDescriptor), + ...options?.layers?.serverEnvironment, + }), + ), + Layer.provide( + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => Effect.succeed(null), + ...options?.layers?.repositoryIdentityResolver, + }), + ), + Layer.provide( + Layer.mergeAll( + Layer.succeed( + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime, + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ + applyConfig: () => Effect.succeed({ status: "disabled" }), + recoveryRequests: Stream.empty, + requestRecovery: () => Effect.void, + withLinkStateLock: (effect) => effect, + ...options?.layers?.cloudManagedEndpointRuntime, + }), + ), + Layer.mock(AgentAwarenessRelay.AgentAwarenessRelay)({ + requestCatchUp: () => Effect.void, + ...options?.layers?.agentAwarenessRelay, }), ), - Layer.mock(AgentAwarenessRelay.AgentAwarenessRelay)({ - requestCatchUp: () => Effect.void, - ...options?.layers?.agentAwarenessRelay, - }), ), - ), - Layer.provide( - Layer.succeed( - RelayClient.RelayClient, - RelayClient.RelayClient.of({ - resolve: Effect.succeed({ - status: "missing", - version: RelayClient.CLOUDFLARED_VERSION, + Layer.provide( + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.succeed({ + status: "missing", + version: RelayClient.CLOUDFLARED_VERSION, + }), + install: Effect.die("unused relay-client install"), + installWithProgress: () => Effect.die("unused relay-client install"), + ...options?.layers?.relayClient, }), - install: Effect.die("unused relay-client install"), - installWithProgress: () => Effect.die("unused relay-client install"), - ...options?.layers?.relayClient, + ), + ), + Layer.provide( + Layer.mock(CloudCliTokenManager.CloudCliTokenManager)({ + get: Effect.die(new Error("Unexpected T3 Connect CLI authorization request.")), + getExisting: Effect.succeedNone, + hasCredential: Effect.succeed(false), + clear: Effect.void, + ...options?.layers?.cloudCliTokenManager, }), ), - ), - Layer.provide( - Layer.mock(CloudCliTokenManager.CloudCliTokenManager)({ - get: Effect.die(new Error("Unexpected T3 Connect CLI authorization request.")), - getExisting: Effect.succeedNone, - hasCredential: Effect.succeed(false), - clear: Effect.void, - ...options?.layers?.cloudCliTokenManager, + Layer.updateService(PairingGrantStore.PairingGrantStore, (grants) => { + const subscribed = options?.onPairingChangesSubscribed; + if (!subscribed) return grants; + return { + ...grants, + streamChanges: Stream.unwrap( + Effect.gen(function* () { + const changes = + yield* Queue.unbounded(); + yield* grants.streamChanges.pipe( + Stream.runForEach((change) => Queue.offer(changes, change)), + Effect.forkScoped({ startImmediately: true }), + ); + yield* subscribed; + return Stream.fromQueue(changes); + }), + ), + }; }), - ), - Layer.updateService(PairingGrantStore.PairingGrantStore, (grants) => { - const subscribed = options?.onPairingChangesSubscribed; - if (!subscribed) return grants; - return { - ...grants, - streamChanges: Stream.unwrap( - Effect.gen(function* () { - const changes = yield* Queue.unbounded(); - yield* grants.streamChanges.pipe( - Stream.runForEach((change) => Queue.offer(changes, change)), - Effect.forkScoped({ startImmediately: true }), - ); - yield* subscribed; - return Stream.fromQueue(changes); - }), - ), - }; - }), - Layer.provideMerge(makeAuthTestLayer()), - Layer.provideMerge(ServerSecretStore.layer), - Layer.provide(workspaceAndProjectServicesLayer), - Layer.provideMerge( - options?.layers?.httpClient === undefined - ? FetchHttpClient.layer - : Layer.succeed(HttpClient.HttpClient, options.layers.httpClient), - ), - Layer.provide(GitHubCli.layer.pipe(Layer.provideMerge(VcsProcess.layer))), - Layer.provide(layerConfig), - ); + Layer.provideMerge(makeAuthTestLayer()), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provide(workspaceAndProjectServicesLayer), + Layer.provideMerge( + options?.layers?.httpClient === undefined + ? FetchHttpClient.layer + : Layer.succeed(HttpClient.HttpClient, options.layers.httpClient), + ), + ) + .pipe( + Layer.provide(GitHubCli.layer.pipe(Layer.provideMerge(VcsProcess.layer))), + Layer.provide(layerConfig), + ); yield* Layer.build(appLayer); return config; @@ -7939,7 +7952,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(readError.cwd, workspaceDir); assert.equal(readError.relativePath, "linked-outside.txt"); assert.equal(readError.failure, "resolved_path_outside_root"); - assert.equal(readError.resolvedPath, resolvedOutsideFile); + assert.isDefined(readError.resolvedPath); + assert.equal(path.basename(readError.resolvedPath), path.basename(resolvedOutsideFile)); + assert.equal(yield* fs.readFileString(readError.resolvedPath), "outside\n"); assert.isDefined(readError.cause); if ( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 43bba34bd8c5..0536b75ba064 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -116,6 +116,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; +import * as SourceControlPanelService from "./sourceControl/SourceControlPanelService.ts"; import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; @@ -347,13 +348,14 @@ const RepositoryIdentityResolverLayerLive = Layer.effect( }), ).pipe(Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(ProcessRunner.layer)); +const SourceControlRateLimitLayerLive = SourceControlRateLimit.layer; const PullRequestServiceLive = PullRequestService.layer.pipe( Layer.provide(PullRequestProviderRegistry.layer), // Where the viewed-file marks live for a host that keeps none of its own. Layer.provide(PullRequestFilesViewed.layer), Layer.provide(PullRequestReadCache.layer), Layer.provide(SourceControlProviderRegistryLayerLive), - Layer.provide(SourceControlRateLimit.layer), + Layer.provide(SourceControlRateLimitLayerLive), ); const GitManagerLayerLive = GitManager.layer.pipe( @@ -385,6 +387,17 @@ const ProjectCloneTrackerLayerLive = ProjectCloneTracker.layer.pipe( Layer.provide(SourceControlRepositoryServiceLayerLive), ); +const SourceControlPanelServiceLayerLive = SourceControlPanelService.layer.pipe( + Layer.provideMerge(GitWorkflowLayerLive), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(SourceControlProviderRegistryLayerLive), + Layer.provide(SourceControlRateLimitLayerLive), + Layer.provideMerge( + TextGeneration.layer.pipe(Layer.provide(SourceControlProviderRegistryLayerLive)), + ), + Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), +); + const ReviewLayerLive = ReviewService.layer.pipe( Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), @@ -398,6 +411,7 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge(ReviewLayerLive), Layer.provideMerge(SourceControlRepositoryServiceLayerLive), Layer.provideMerge(ProjectCloneTrackerLayerLive), + Layer.provideMerge(SourceControlPanelServiceLayerLive), Layer.provideMerge( VcsStatusBroadcaster.layer.pipe( Layer.provide(GitWorkflowLayerLive), diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 24b28af13fe4..d2461e2ecab0 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -190,6 +190,51 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("lists pull requests against an explicit Azure organization", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.listPullRequests({ + cwd: "/repo", + headSelector: "feature/provider", + organization: "https://dev.azure.com/acme", + repository: "repo", + project: "project", + state: "open", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "list", + "--detect", + "true", + "--organization", + "https://dev.azure.com/acme", + "--repository", + "repo", + "--project", + "project", + "--source-branch", + "feature/provider", + "--status", + "active", + "--top", + "20", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("reads repository clone URLs", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -223,6 +268,77 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("lists pull requests in bulk without a source branch filter", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.listPullRequests({ cwd: "/repo", state: "open", limit: 100 }); + + expect(mockRun).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.not.arrayContaining(["--source-branch"]), + }), + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("requests commit avatars from the explicit Azure organization", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + author: { + imageUrl: "https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.123", + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getCommitAvatarUrl({ + cwd: "/repo", + organization: "https://dev.azure.com/acme", + project: "project", + repository: "repo", + sha: "abc123", + }); + + assert.strictEqual( + result, + "https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.123", + ); + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "devops", + "invoke", + "--organization", + "https://dev.azure.com/acme", + "--area", + "git", + "--resource", + "commits", + "--route-parameters", + "project=project", + "repositoryId=repo", + "commitId=abc123", + "--api-version", + "7.1", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("creates repositories through Azure Repos", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index f5e436ef6c56..b8028f06df76 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -173,6 +173,7 @@ const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ "getRepositoryCloneUrls", "getDefaultBranch", "createRepository", + "getCommitAvatarUrl", ]); export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedError()( @@ -221,8 +222,11 @@ export class AzureDevOpsCli extends Context.Service< readonly listPullRequests: (input: { readonly cwd: string; - readonly headSelector: string; + readonly headSelector?: string; readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly organization?: string; + readonly repository?: string; + readonly project?: string; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; }) => Effect.Effect, AzureDevOpsCliError>; @@ -237,6 +241,14 @@ export class AzureDevOpsCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + readonly getCommitAvatarUrl: (input: { + readonly cwd: string; + readonly organization: string; + readonly repository: string; + readonly sha: string; + readonly project?: string; + }) => Effect.Effect; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -297,6 +309,14 @@ const RawAzureDevOpsRepositorySchema = Schema.Struct({ defaultBranch: Schema.optional(Schema.NullOr(Schema.String)), }); +const RawAzureDevOpsCommitSchema = Schema.Struct({ + author: Schema.optional( + Schema.Struct({ + imageUrl: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); + function normalizeDefaultBranch(value: string | null | undefined): string | null { const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? ""; return trimmed.length > 0 ? trimmed : null; @@ -386,8 +406,13 @@ export const make = Effect.gen(function* () { return AzureDevOpsCli.of({ execute, - listPullRequests: (input) => - executeJson({ + listPullRequests: (input) => { + const sourceBranch = + input.source?.refName ?? + (input.headSelector === undefined + ? null + : SourceControlProvider.sourceBranch({ headSelector: input.headSelector })); + return executeJson({ cwd: input.cwd, args: [ "repos", @@ -395,8 +420,10 @@ export const make = Effect.gen(function* () { "list", "--detect", "true", - "--source-branch", - SourceControlProvider.sourceBranch(input), + ...(input.organization ? ["--organization", input.organization] : []), + ...(input.repository ? ["--repository", input.repository] : []), + ...(input.project ? ["--project", input.project] : []), + ...(sourceBranch ? ["--source-branch", sourceBranch] : []), "--status", toAzureStatus(input.state), "--top", @@ -425,7 +452,8 @@ export const make = Effect.gen(function* () { }), ), ), - ), + ); + }, getPullRequest: (input) => executeJson({ cwd: input.cwd, @@ -476,6 +504,35 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + getCommitAvatarUrl: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "devops", + "invoke", + "--organization", + input.organization, + "--area", + "git", + "--resource", + "commits", + "--route-parameters", + ...(input.project ? [`project=${input.project}`] : []), + `repositoryId=${input.repository}`, + `commitId=${input.sha}`, + "--api-version", + "7.1", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson(raw, RawAzureDevOpsCommitSchema, "getCommitAvatarUrl", input.cwd), + ), + Effect.map((commit) => { + const avatarUrl = commit.author?.imageUrl?.trim(); + return avatarUrl && avatarUrl.length > 0 ? avatarUrl : null; + }), + ), createRepository: (input) => { const repository = parseRepositorySpecifier(input.repository); // Azure Repos access is governed by project/organization permissions. diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 8e55d453b224..733e7396c735 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -49,7 +49,163 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" }), ); -it.effect("adds change-request context while retaining Azure CLI causes", () => +it.effect("lists Azure DevOps PRs against the requested remote repository context", () => + Effect.gen(function* () { + let listInput: + | Parameters[0] + | null = null; + const provider = yield* makeProvider({ + listPullRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + context: { + provider: { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: "https://dev.azure.com", + }, + remoteName: "upstream", + remoteUrl: "https://dev.azure.com/acme/project/_git/repo", + }, + headSelector: "feature/provider", + state: "open", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + organization: "https://dev.azure.com/acme", + repository: "repo", + project: "project", + state: "open", + limit: 10, + }); + }), +); + +it.effect("preserves Azure DevOps fallback project context when _git is absent", () => + Effect.gen(function* () { + let listInput: + | Parameters[0] + | null = null; + const provider = yield* makeProvider({ + listPullRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + context: { + provider: { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: "https://dev.azure.com", + }, + remoteName: "legacy", + remoteUrl: "https://dev.azure.com/acme/project/repo", + }, + headSelector: "feature/provider", + state: "open", + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + organization: "https://dev.azure.com/acme", + repository: "repo", + project: "project", + state: "open", + }); + }), +); + +it.effect("uses Azure DevOps commit author image URLs from the remote repository context", () => + Effect.gen(function* () { + let avatarInput: + | Parameters[0] + | null = null; + const provider = yield* makeProvider({ + getCommitAvatarUrl: (input) => { + avatarInput = input; + return Effect.succeed( + "https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.123", + ); + }, + }); + + const avatarUrl = yield* provider.getCommitAvatarUrl({ + cwd: "/repo", + context: { + provider: { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: "https://dev.azure.com", + }, + remoteName: "upstream", + remoteUrl: "https://dev.azure.com/acme/project/_git/repo", + }, + sha: "abc123", + }); + + assert.strictEqual( + avatarUrl, + "https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.123", + ); + assert.deepStrictEqual(avatarInput, { + cwd: "/repo", + organization: "https://dev.azure.com/acme", + repository: "repo", + project: "project", + sha: "abc123", + }); + }), +); + +it.effect("routes Azure SSH avatar lookups through the remote organization", () => + Effect.gen(function* () { + let avatarInput: + | Parameters[0] + | null = null; + const provider = yield* makeProvider({ + getCommitAvatarUrl: (input) => { + avatarInput = input; + return Effect.succeed(null); + }, + }); + + yield* provider.getCommitAvatarUrl({ + cwd: "/repo", + context: { + provider: { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: "https://dev.azure.com", + }, + remoteName: "upstream", + remoteUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + }, + sha: "abc123", + }); + + assert.deepStrictEqual(avatarInput, { + cwd: "/repo", + organization: "https://dev.azure.com/acme", + repository: "repo", + project: "project", + sha: "abc123", + }); + }), +); + +it.effect("adds change-request context while bounding Azure CLI causes", () => Effect.gen(function* () { const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ operation: "execute", @@ -84,7 +240,14 @@ it.effect("adds change-request context while retaining Azure CLI causes", () => detail: "Azure DevOps CLI command failed.", }, ); - assert.strictEqual(error.cause, cause); + assert.deepStrictEqual(error.cause, { + _tag: "AzureDevOpsCommandFailedError", + name: "AzureDevOpsCommandFailedError", + operation: "execute", + command: "az", + detail: "Azure DevOps CLI command failed.", + message: "Azure DevOps CLI failed in execute: Azure DevOps CLI command failed.", + }); assert.equal(error.message.includes("raw upstream detail"), false); }), ); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index c2f6a5efc65a..607cabac7f29 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -1,5 +1,5 @@ import * as Effect from "effect/Effect"; -import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; +import type { ChangeRequest } from "@t3tools/contracts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -81,55 +81,112 @@ function toChangeRequest(summary: { }; } +function azureRepositoryFromContext( + context: SourceControlProvider.SourceControlProviderContext | undefined, +): + | { + readonly organization: string; + readonly repository: string; + readonly project?: string; + } + | undefined { + if (!context) return undefined; + const path = SourceControlProvider.repositoryPathFromRemoteUrl(context.remoteUrl); + if (!path) return undefined; + const parts = path + .split("/") + .map((part) => part.trim()) + .filter(Boolean); + const organization = (() => { + try { + const url = new URL(context.remoteUrl); + const hostname = url.hostname.toLowerCase(); + if (hostname === "dev.azure.com") { + const name = parts[0]; + return name ? `${url.origin}/${encodeURIComponent(name)}` : undefined; + } + if (hostname.endsWith(".visualstudio.com")) { + return url.origin; + } + } catch { + if (parts[0]?.toLowerCase() === "v3" && parts[1]) { + return `https://dev.azure.com/${encodeURIComponent(parts[1])}`; + } + } + return undefined; + })(); + if (!organization) return undefined; + const gitIndex = parts.findIndex((part) => part.toLowerCase() === "_git"); + const gitProject = parts[gitIndex - 1]; + const gitRepository = parts[gitIndex + 1]; + if (gitIndex >= 1 && gitProject && gitRepository) { + return { organization, project: gitProject, repository: gitRepository }; + } + const sshProject = parts[2]; + const sshRepository = parts[3]; + if (parts[0]?.toLowerCase() === "v3" && sshProject && sshRepository) { + return { organization, project: sshProject, repository: sshRepository }; + } + const fallbackProject = parts.at(-2); + const repository = parts.at(-1); + return repository + ? { + organization, + repository, + ...(fallbackProject ? { project: fallbackProject } : {}), + } + : undefined; +} + export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; return SourceControlProvider.SourceControlProvider.of({ kind: "azure-devops", listChangeRequests: (input) => { - const source = SourceControlProvider.sourceControlRefFromInput(input); + const source = input.headSelector + ? SourceControlProvider.sourceControlRefFromInput({ headSelector: input.headSelector }) + : undefined; + const repository = azureRepositoryFromContext(input.context); return azure .listPullRequests({ cwd: input.cwd, - headSelector: input.headSelector, + ...(input.headSelector ? { headSelector: input.headSelector } : {}), ...(source !== undefined ? { source } : {}), + ...(repository !== undefined + ? { + organization: repository.organization, + repository: repository.repository, + ...(repository.project !== undefined ? { project: repository.project } : {}), + } + : {}), state: input.state, ...(input.limit !== undefined ? { limit: input.limit } : {}), }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "listChangeRequests", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "listChangeRequests", + cwd: input.cwd, + ...(input.headSelector ? { reference: input.headSelector } : {}), + error, + }), ), ); }, getChangeRequest: (input) => azure.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "getChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "getChangeRequest", + cwd: input.cwd, + reference: input.reference, + error, + }), ), ), createChangeRequest: (input) => { @@ -145,68 +202,73 @@ export const make = Effect.gen(function* () { bodyFile: input.bodyFile, }) .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "createChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "createChangeRequest", + cwd: input.cwd, + reference: input.headSelector, + error, + }), ), ); }, getRepositoryCloneUrls: (input) => azure.getRepositoryCloneUrls(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "getRepositoryCloneUrls", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + repository: input.repository, + error, + }), ), ), - createRepository: (input) => - azure.createRepository(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ + getCommitAvatarUrl: (input) => { + const repository = azureRepositoryFromContext(input.context); + if (!repository) { + return Effect.succeed(null); + } + return azure + .getCommitAvatarUrl({ + cwd: input.cwd, + ...repository, + sha: input.sha, + }) + .pipe( + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ provider: "azure-devops", - operation: "createRepository", - command: error.command, + operation: "getCommitAvatarUrl", cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, + reference: input.sha, + error, }), + ), + ); + }, + createRepository: (input) => + azure.createRepository(input).pipe( + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "createRepository", + cwd: input.cwd, + repository: input.repository, + error, + }), ), ), getDefaultBranch: (input) => azure.getDefaultBranch({ cwd: input.cwd }).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "getDefaultBranch", - command: error.command, - cwd: input.cwd, - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "getDefaultBranch", + cwd: input.cwd, + error, + }), ), ), checkoutChangeRequest: (input) => @@ -217,19 +279,14 @@ export const make = Effect.gen(function* () { ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), }) .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "checkoutChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "checkoutChangeRequest", + cwd: input.cwd, + reference: input.reference, + error, + }), ), ), }); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 83965a38b21c..36da8d79753c 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -273,6 +273,22 @@ it.effect("lists closed pull requests with both closed Bitbucket states", () => }).pipe(Effect.provide(layer)); }); +it.effect("lists pull requests in bulk without a source branch filter", () => { + const { execute, layer } = makeLayer({ response: () => Response.json({ values: [] }) }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.listPullRequests({ cwd: "/repo", state: "open", limit: 100 }); + + assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ + ["pagelen", "50"], + ["sort", "-updated_on"], + ["q", 'state = "OPEN"'], + ["state", "OPEN"], + ]); + }).pipe(Effect.provide(layer)); +}); + it.effect("expands all-state pull request listing instead of relying on Bitbucket defaults", () => { const { execute, layer } = makeLayer({ response: () => diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 2404e6b497e3..df04c0506e63 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -54,6 +54,7 @@ const BitbucketApiOperation = Schema.Literals([ "resolveRepository", "getRepository", "getBranchingModel", + "getCommitAvatarUrl", "getPullRequest", "listPullRequests", "createRepository", @@ -311,6 +312,28 @@ const RawBitbucketBranchingModelSchema = Schema.Struct({ ), }); +const RawBitbucketCommitSchema = Schema.Struct({ + author: Schema.optional( + Schema.Struct({ + user: Schema.optional( + Schema.NullOr( + Schema.Struct({ + links: Schema.optional( + Schema.Struct({ + avatar: Schema.optional( + Schema.Struct({ + href: TrimmedNonEmptyString, + }), + ), + }), + ), + }), + ), + ), + }), + ), +}); + const BitbucketUserSchema = Schema.Struct({ username: Schema.optional(TrimmedNonEmptyString), display_name: Schema.optional(TrimmedNonEmptyString), @@ -347,7 +370,7 @@ export class BitbucketApi extends Context.Service< readonly listPullRequests: (input: { readonly cwd: string; readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly headSelector: string; + readonly headSelector?: string; readonly source?: SourceControlProvider.SourceControlRefSelector; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; @@ -362,6 +385,11 @@ export class BitbucketApi extends Context.Service< readonly context?: SourceControlProvider.SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; + readonly getCommitAvatarUrl: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly sha: string; + }) => Effect.Effect; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -977,11 +1005,18 @@ export const make = Effect.gen(function* () { resolveRepository(input).pipe( Effect.flatMap((repository) => { const states = toBitbucketStates(input.state); + const sourceBranch = + input.source?.refName ?? + (input.headSelector === undefined + ? null + : SourceControlProvider.sourceBranch({ headSelector: input.headSelector })); const query: Record> = { pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), sort: "-updated_on", q: bitbucketQueryString([ - `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`, + ...(sourceBranch + ? [`source.branch.name = "${sourceBranch.replaceAll('"', '\\"')}"`] + : []), bitbucketStateFilter(states), ]), state: states, @@ -1004,6 +1039,21 @@ export const make = Effect.gen(function* () { getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), getRepositoryCloneUrls: (input) => getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), + getCommitAvatarUrl: (input) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => + executeJson( + "getCommitAvatarUrl", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/commit/${encodeURIComponent(input.sha)}`, + ), + ), + RawBitbucketCommitSchema, + ), + ), + Effect.map((commit) => commit.author?.user?.links?.avatar?.href ?? null), + ), createRepository: (input) => requireRepositoryLocator(input.repository).pipe( Effect.flatMap((repository) => diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 52a15547c573..bd82bc7810a1 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -149,3 +149,63 @@ it.effect("creates Bitbucket PRs through provider-neutral input names", () => }); }), ); + +it.effect("uses Bitbucket API repository detection for default branch lookup", () => + Effect.gen(function* () { + let cwdInput: string | null = null; + const provider = yield* makeProvider({ + getDefaultBranch: (input) => { + cwdInput = input.cwd; + return Effect.succeed("main"); + }, + }); + + const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + assert.strictEqual(cwdInput, "/repo"); + }), +); + +it.effect("uses Bitbucket account avatar links through the provider API", () => + Effect.gen(function* () { + let avatarInput: + | Parameters[0] + | null = null; + const provider = yield* makeProvider({ + getCommitAvatarUrl: (input) => { + avatarInput = input; + return Effect.succeed("https://bitbucket.org/account/user/example/avatar/32/"); + }, + }); + + const avatarUrl = yield* provider.getCommitAvatarUrl({ + cwd: "/repo", + context: { + provider: { + kind: "bitbucket", + name: "Bitbucket", + baseUrl: "https://bitbucket.org", + }, + remoteName: "origin", + remoteUrl: "https://bitbucket.org/workspace/repo.git", + }, + sha: "abc123", + }); + + assert.strictEqual(avatarUrl, "https://bitbucket.org/account/user/example/avatar/32/"); + assert.deepStrictEqual(avatarInput, { + cwd: "/repo", + context: { + provider: { + kind: "bitbucket", + name: "Bitbucket", + baseUrl: "https://bitbucket.org", + }, + remoteName: "origin", + remoteUrl: "https://bitbucket.org/workspace/repo.git", + }, + sha: "abc123", + }); + }), +); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index f1a01056fbd0..4ac6bb19785e 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -36,12 +36,14 @@ export const make = Effect.gen(function* () { return SourceControlProvider.SourceControlProvider.of({ kind: "bitbucket", listChangeRequests: (input) => { - const source = SourceControlProvider.sourceControlRefFromInput(input); + const source = input.headSelector + ? SourceControlProvider.sourceControlRefFromInput({ headSelector: input.headSelector }) + : undefined; return bitbucket .listPullRequests({ cwd: input.cwd, ...(input.context ? { context: input.context } : {}), - headSelector: input.headSelector, + ...(input.headSelector ? { headSelector: input.headSelector } : {}), ...(source ? { source } : {}), state: input.state, ...(input.limit !== undefined ? { limit: input.limit } : {}), @@ -54,9 +56,13 @@ export const make = Effect.gen(function* () { provider: "bitbucket", operation: "listChangeRequests", cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), + ...(input.headSelector + ? { + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + } + : {}), detail: "Failed to list change requests.", cause: error, }), @@ -125,6 +131,25 @@ export const make = Effect.gen(function* () { }), ), ), + getCommitAvatarUrl: (input) => + bitbucket + .getCommitAvatarUrl({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + sha: input.sha, + }) + .pipe( + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "bitbucket", + operation: "getCommitAvatarUrl", + cwd: input.cwd, + reference: input.sha, + detail: "Failed to get commit author avatar.", + error, + }), + ), + ), createRepository: (input) => bitbucket.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts index 5a501d96b235..933886ae5d02 100644 --- a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts @@ -228,11 +228,19 @@ export const make = Effect.gen(function* () { }); return SourceControlProvider.SourceControlProvider.of({ kind: "forgejo", + getCommitAvatarUrl: () => Effect.succeed(null), listChangeRequests: (input) => Effect.gen(function* () { const repo = yield* cli.resolveRepository(input); - const source = SourceControlProvider.sourceControlRefFromInput(input); - const branch = SourceControlProvider.sourceBranch(input); + const source = + input.source ?? + (input.headSelector + ? SourceControlProvider.sourceControlRefFromInput({ + ...input, + headSelector: input.headSelector, + }) + : undefined); + const branch = source?.refName ?? input.headSelector; const results: ReturnType[] = []; const limit = input.limit ?? 20; for (let page = 1; results.length < limit; page++) { @@ -245,7 +253,7 @@ export const make = Effect.gen(function* () { ); for (const item of items) { if ( - item.head.ref !== branch || + (branch !== undefined && item.head.ref !== branch) || (source?.repository && item.head.repo?.full_name !== source.repository) || (source?.owner && item.head.repo?.owner.login !== source.owner) ) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 8374a44af78f..77c8f659f1c3 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -680,6 +680,35 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("fetches commit author avatar URLs from the GitHub commit API", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed(processOutput("https://avatars.githubusercontent.com/u/101?v=4\n")), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getCommitAvatarUrl({ + cwd: "/repo", + repository: "pingdotgg/t3code", + sha: "a".repeat(40), + }); + + assert.equal(result, "https://avatars.githubusercontent.com/u/101?v=4"); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "api", + "repos/pingdotgg/t3code/commits/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "--jq", + ".author.avatar_url // empty", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("skips invalid entries when parsing pr lists", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -790,6 +819,21 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("lists open pull requests in bulk without a head filter", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.listOpenPullRequests({ cwd: "/repo", limit: 100 }); + + expect(mockRun).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.not.arrayContaining(["--head"]), + }), + ); + }).pipe(Effect.provide(layer)), + ); + it.effect("reads repository clone URLs", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index f1973d1f2a33..c0b99b7f130c 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -301,7 +301,8 @@ export class GitHubCli extends Context.Service< readonly listOpenPullRequests: (input: { readonly cwd: string; - readonly headSelector: string; + readonly headSelector?: string; + readonly repository?: string; readonly limit?: number; readonly rateLimitHost?: string; }) => Effect.Effect, GitHubCliError>; @@ -331,6 +332,13 @@ export class GitHubCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + readonly getCommitAvatarUrl: (input: { + readonly cwd: string; + readonly repository: string; + readonly sha: string; + readonly hostname?: string; + }) => Effect.Effect; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -919,8 +927,8 @@ export const make = Effect.gen(function* () { args: [ "pr", "list", - "--head", - input.headSelector, + ...(input.repository ? ["--repo", input.repository] : []), + ...(input.headSelector ? ["--head", input.headSelector] : []), "--state", "open", "--limit", @@ -1002,6 +1010,22 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + getCommitAvatarUrl: (input) => + execute({ + cwd: input.cwd, + args: [ + "api", + ...(input.hostname !== undefined ? ["--hostname", input.hostname] : []), + `repos/${input.repository}/commits/${input.sha}`, + "--jq", + ".author.avatar_url // empty", + ], + }).pipe( + Effect.map((result) => { + const trimmed = result.stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + }), + ), createRepository: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 2a214fc800ff..b491215cb8c1 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -97,7 +97,59 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); -it.effect("adds safe request context while retaining GitHub CLI causes", () => +for (const { host, remoteUrl } of [ + { + host: "github.example.test", + remoteUrl: "https://github.example.test/acme/repo.git", + }, + { + host: "github.example.test:8443", + remoteUrl: "https://github.example.test:8443/acme/repo.git", + }, + { + host: "github.example.test", + remoteUrl: "git@github.example.test:acme/repo.git", + }, +]) { + it.effect(`lists GitHub PRs against the requested remote repository ${remoteUrl}`, () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = + null; + const provider = yield* makeProvider({ + listOpenPullRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + context: { + provider: { + kind: "github", + name: "GitHub Enterprise", + baseUrl: `https://${host}`, + }, + remoteName: "upstream", + remoteUrl, + }, + headSelector: "feature/provider", + state: "open", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + repository: `${host}/acme/repo`, + rateLimitHost: host, + limit: 10, + }); + }), + ); +} + +it.effect("adds safe request context while bounding GitHub CLI causes", () => Effect.gen(function* () { const cause = new GitHubCli.GitHubPullRequestNotFoundError({ command: "gh", @@ -133,7 +185,14 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => detail: "Pull request not found. Check the PR number or URL and try again.", }, ); - assert.strictEqual(error.cause, cause); + assert.deepStrictEqual(error.cause, { + _tag: "GitHubPullRequestNotFoundError", + name: "GitHubPullRequestNotFoundError", + command: "gh", + detail: "Pull request not found. Check the PR number or URL and try again.", + message: + "GitHub CLI failed in execute: Pull request not found. Check the PR number or URL and try again.", + }); assert.equal(error.message.includes("raw upstream detail"), false); }), ); @@ -189,6 +248,49 @@ it.effect("lists change request history through the batched head lookup", () => }), ); +it.effect("lists repository-wide history without passing an empty head selector", () => + Effect.gen(function* () { + let command: Parameters[0] | undefined; + const provider = yield* makeProvider({ + execute: (input) => { + command = input; + return Effect.succeed( + processResult( + JSON.stringify([ + { + number: 8, + title: "Merged work", + url: "https://enterprise.test/acme/web/pull/8", + baseRefName: "main", + headRefName: "feature/merged", + state: "MERGED", + }, + ]), + ), + ); + }, + }); + const requests = yield* provider.listChangeRequests({ + cwd: "/repo", + context: { + provider: { kind: "github", name: "Enterprise", baseUrl: "https://enterprise.test:8443" }, + remoteName: "upstream", + remoteUrl: "https://enterprise.test:8443/acme/web.git", + }, + state: "all", + }); + assert.strictEqual(command?.rateLimitHost, "enterprise.test:8443"); + assert.deepStrictEqual(command?.args.slice(0, 4), [ + "pr", + "list", + "--repo", + "enterprise.test:8443/acme/web", + ]); + assert.strictEqual(command?.args.includes("--head"), false); + assert.strictEqual(requests[0]?.state, "merged"); + }), +); + it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { let createInput: Parameters[0] | null = diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 905146d5a733..27945481b420 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -1,11 +1,15 @@ +import { SourceControlProviderError } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; +import * as Result from "effect/Result"; +import type { ChangeRequest } from "@t3tools/contracts"; import * as GitHubCli from "./GitHubCli.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; +import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import { combinedAuthOutput, @@ -49,6 +53,34 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } +function repositoryFromContext( + context: SourceControlProvider.SourceControlProviderContext | undefined, +): string | undefined { + if (!context) return undefined; + const repository = SourceControlProvider.repositoryPathFromRemoteUrl(context.remoteUrl); + if (!repository) return undefined; + try { + const host = new URL(context.provider.baseUrl).host; + return host && host !== "github.com" ? `${host}/${repository}` : repository; + } catch { + return repository; + } +} + +function repositoryContextFromProviderContext( + context: SourceControlProvider.SourceControlProviderContext | undefined, +): { readonly repository: string; readonly hostname?: string } | undefined { + if (!context) return undefined; + const repository = SourceControlProvider.repositoryPathFromRemoteUrl(context.remoteUrl); + if (!repository) return undefined; + try { + const hostname = new URL(context.provider.baseUrl).host; + return hostname && hostname !== "github.com" ? { repository, hostname } : { repository }; + } catch { + return { repository }; + } +} + function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); @@ -116,72 +148,108 @@ export const make = Effect.gen(function* () { const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = (input) => { + const repository = repositoryFromContext(input.context); if (input.state === "open") { return github .listOpenPullRequests({ cwd: input.cwd, - headSelector: input.headSelector, + ...(input.headSelector ? { headSelector: input.headSelector } : {}), ...(input.context === undefined ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), + : { + rateLimitHost: SourceControlRateLimit.providerHost( + input.context.provider.baseUrl, + "github", + ), + }), + ...(repository ? { repository } : {}), ...(input.limit !== undefined ? { limit: input.limit } : {}), }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + cwd: input.cwd, + ...(input.headSelector ? { reference: input.headSelector } : {}), + error, + }), ), ); } - return github - .listPullRequestsByHead({ - cwd: input.cwd, - headSelector: input.headSelector, - state: input.state, - limit: input.limit ?? 20, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.map((items) => - items.map(({ updatedAt, ...summary }) => ({ - ...toChangeRequest({ - ...summary, - ...(Option.isSome(updatedAt) - ? { updatedAt: DateTime.formatIso(updatedAt.value) } - : {}), - }), - updatedAt, - })), - ), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, + const rateLimitHost = + input.context === undefined + ? {} + : { + rateLimitHost: SourceControlRateLimit.providerHost( + input.context.provider.baseUrl, + "github", + ), + }; + const lookup = input.headSelector + ? github.listPullRequestsByHead({ + cwd: input.cwd, + headSelector: input.headSelector, + state: input.state, + limit: input.limit ?? 20, + ...rateLimitHost, + }) + : github + .execute({ + cwd: input.cwd, + ...rateLimitHost, + args: [ + "pr", + "list", + ...(repository ? ["--repo", repository] : []), + "--state", + input.state, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }) + .pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) return Effect.succeed([]); + const decoded = decodeGitHubPullRequestListJson(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubCli.GitHubChangeRequestListDecodeError({ + command: "gh", + cwd: input.cwd, + cause: decoded.failure, + }), + ); }), - ), - ); + ); + return lookup.pipe( + Effect.map((items) => + items.map(({ updatedAt, ...summary }) => ({ + ...toChangeRequest({ + ...summary, + ...(Option.isSome(updatedAt) + ? { updatedAt: DateTime.formatIso(updatedAt.value) } + : {}), + }), + updatedAt, + })), + ), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + cwd: input.cwd, + ...(input.headSelector ? { reference: input.headSelector } : {}), + error, + }), + ), + ); }; const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* ( @@ -241,23 +309,23 @@ export const make = Effect.gen(function* () { ...input, ...(input.context === undefined ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), + : { + rateLimitHost: SourceControlRateLimit.providerHost( + input.context.provider.baseUrl, + "github", + ), + }), }) .pipe( Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "getChangeRequest", + cwd: input.cwd, + reference: input.reference, + error, + }), ), ), createChangeRequest: (input) => @@ -270,53 +338,61 @@ export const make = Effect.gen(function* () { bodyFile: input.bodyFile, }) .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "createChangeRequest", + cwd: input.cwd, + reference: input.headSelector, + error, + }), ), ), getRepositoryCloneUrls: (input) => github.getRepositoryCloneUrls(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getRepositoryCloneUrls", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + repository: input.repository, + error, + }), ), ), - createRepository: (input) => - github.createRepository(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ + getCommitAvatarUrl: (input) => { + const repository = repositoryContextFromProviderContext(input.context); + if (!repository) { + return Effect.succeed(null); + } + return github + .getCommitAvatarUrl({ + cwd: input.cwd, + ...repository, + sha: input.sha, + }) + .pipe( + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ provider: "github", - operation: "createRepository", - command: error.command, + operation: "getCommitAvatarUrl", cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, + reference: input.sha, + error, }), + ), + ); + }, + createRepository: (input) => + github.createRepository(input).pipe( + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "createRepository", + cwd: input.cwd, + repository: input.repository, + error, + }), ), ), getDefaultBranch: (input) => @@ -325,36 +401,33 @@ export const make = Effect.gen(function* () { ...input, ...(input.context === undefined ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), + : { + rateLimitHost: SourceControlRateLimit.providerHost( + input.context.provider.baseUrl, + "github", + ), + }), }) .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getDefaultBranch", - command: error.command, - cwd: input.cwd, - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "getDefaultBranch", + cwd: input.cwd, + error, + }), ), ), checkoutChangeRequest: (input) => github.checkoutPullRequest(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "checkoutChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "checkoutChangeRequest", + cwd: input.cwd, + reference: input.reference, + error, + }), ), ), }); diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index c5f22fe3088f..d1cf5d58a348 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -190,6 +190,164 @@ layer("GitLabCli.layer", (it) => { }), ); + it.effect("lists merge requests in bulk without a source branch filter", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.listMergeRequests({ cwd: "/repo", state: "open", limit: 100 }); + + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.not.arrayContaining(["--source-branch"]), + }), + ); + }), + ); + + it.effect("reads GitLab-hosted avatar URLs through the official Avatar API", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + avatar_url: "https://gitlab.example.test/uploads/-/system/user/avatar/123/avatar.png", + }), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getCommitAvatarUrl({ + cwd: "/repo", + email: "author@example.test", + providerBaseUrl: "https://gitlab.example.test", + }); + }); + + assert.strictEqual( + result, + "https://gitlab.example.test/uploads/-/system/user/avatar/123/avatar.png", + ); + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: ["api", "--hostname", "gitlab.example.test", "avatar?email=author%40example.test"], + }), + ); + }), + ); + + it.effect("passes through external GitLab Avatar API fallback URLs", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + avatar_url: "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61", + }), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getCommitAvatarUrl({ + cwd: "/repo", + email: "author@example.test", + providerBaseUrl: "https://gitlab.example.test", + }); + }); + + assert.strictEqual( + result, + "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61", + ); + }), + ); + + it.effect("preserves structured process failures when normalizing CLI errors", () => + Effect.gen(function* () { + const cause = { + _tag: "VcsProcessSpawnError", + detail: "Command not found: glab", + }; + mockedRun.mockReturnValueOnce(Effect.fail(cause) as never); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab + .execute({ + cwd: "/repo", + args: ["mr", "list"], + }) + .pipe(Effect.flip); + }); + + expect(error.detail).toBe("GitLab CLI (`glab`) is required but not available on PATH."); + expect(error._tag).toBe("GitLabCliUnavailableError"); + expect(error.cause).toEqual({ + _tag: "VcsProcessSpawnError", + detail: "Command not found: glab", + }); + }), + ); + + it.effect("classifies GitLab CLI errors from detail text without rendering tags", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "gitlab.execute", + command: "glab mr view 42", + cwd: "/repo", + exitCode: 1, + detail: "fatal: could not read from remote repository", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause) as never); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab + .execute({ + cwd: "/repo", + args: ["mr", "view", "42"], + }) + .pipe(Effect.flip); + }); + + expect(error.detail).toBe("GitLab CLI command failed."); + expect(error.detail).not.toContain("VcsProcessExitError"); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("does not classify ambiguous token text as authentication failures", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.fail({ + _tag: "VcsProcessExitError", + detail: "GraphQL parser found token near merge request query", + }) as never, + ); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab + .execute({ + cwd: "/repo", + args: ["mr", "list"], + }) + .pipe(Effect.flip); + }); + + expect(error._tag).toBe("GitLabCliCommandError"); + expect(error.detail).toBe("GitLab CLI command failed."); + }), + ); + it.effect("creates merge requests through the GitLab API without placing the body in argv", () => Effect.gen(function* () { mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9d03e1ab8aa5..29d6d4eff09f 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -8,6 +8,7 @@ import * as Schema from "effect/Schema"; import type * as DateTime from "effect/DateTime"; import { + TrimmedString, TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, @@ -208,6 +209,22 @@ export class GitLabRepositoryDecodeError extends Schema.TaggedError()( + "GitLabAvatarDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("getCommitAvatarUrl"), + }, +) { + get detail(): string { + return "GitLab CLI returned invalid avatar JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + export class GitLabNamespaceDecodeError extends Schema.TaggedError()( "GitLabNamespaceDecodeError", { @@ -234,6 +251,7 @@ export const GitLabCliError = Schema.Union([ GitLabMergeRequestListDecodeError, GitLabMergeRequestDecodeError, GitLabRepositoryDecodeError, + GitLabAvatarDecodeError, GitLabNamespaceDecodeError, ]); export type GitLabCliError = typeof GitLabCliError.Type; @@ -274,8 +292,9 @@ export class GitLabCli extends Context.Service< readonly listMergeRequests: (input: { readonly cwd: string; - readonly headSelector: string; + readonly headSelector?: string; readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly repository?: string; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; }) => Effect.Effect, GitLabCliError>; @@ -290,6 +309,12 @@ export class GitLabCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + readonly getCommitAvatarUrl: (input: { + readonly cwd: string; + readonly email: string; + readonly providerBaseUrl: string; + }) => Effect.Effect; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -329,6 +354,10 @@ const RawGitLabDefaultBranchSchema = Schema.Struct({ default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), }); +const RawGitLabAvatarSchema = Schema.Struct({ + avatar_url: Schema.optional(Schema.NullOr(TrimmedString)), +}); + const RawGitLabNamespaceSchema = Schema.Struct({ id: Schema.Number, }); @@ -339,6 +368,7 @@ const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect( const decodeGitLabDefaultBranch = Schema.decodeEffect( Schema.fromJsonString(RawGitLabDefaultBranchSchema), ); +const decodeGitLabAvatar = Schema.decodeEffect(Schema.fromJsonString(RawGitLabAvatarSchema)); const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema)); function normalizeRepositoryCloneUrls( @@ -371,10 +401,13 @@ function normalizeHeadSelector(headSelector: string): string { } function sourceRefName(input: { - readonly headSelector: string; + readonly headSelector?: string; readonly source?: SourceControlProvider.SourceControlRefSelector; -}): string { - return input.source?.refName ?? normalizeHeadSelector(input.headSelector); +}): string | null { + return ( + input.source?.refName ?? + (input.headSelector === undefined ? null : normalizeHeadSelector(input.headSelector)) + ); } function sourceProjectIdentifier( @@ -408,6 +441,14 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } +function gitLabHostname(baseUrl: string): string | null { + try { + return new URL(baseUrl).host; + } catch { + return null; + } +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; @@ -455,14 +496,15 @@ export const make = Effect.gen(function* () { return GitLabCli.of({ execute, - listMergeRequests: (input) => - execute({ + listMergeRequests: (input) => { + const sourceBranch = sourceRefName(input); + return execute({ cwd: input.cwd, args: [ "mr", "list", - "--source-branch", - sourceRefName(input), + ...(input.repository ? ["--repo", input.repository] : []), + ...(sourceBranch ? ["--source-branch", sourceBranch] : []), ...stateArgs(input.state), "--per-page", String(input.limit ?? 20), @@ -491,7 +533,8 @@ export const make = Effect.gen(function* () { }), ), ), - ), + ); + }, getMergeRequest: (input) => executeMergeRequest({ cwd: input.cwd, @@ -541,6 +584,45 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + getCommitAvatarUrl: (input) => { + const email = input.email.trim(); + if (!email || !email.includes("@")) { + return Effect.succeed(null); + } + const hostname = gitLabHostname(input.providerBaseUrl); + const query = new URLSearchParams({ email }).toString(); + + return execute({ + cwd: input.cwd, + args: [ + "api", + ...(hostname && hostname !== "gitlab.com" ? ["--hostname", hostname] : []), + `avatar?${query}`, + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabAvatar(raw).pipe( + Effect.mapError( + (cause) => + new GitLabAvatarDecodeError({ + operation: "getCommitAvatarUrl", + command: "glab", + cwd: input.cwd, + cause, + }), + ), + ), + ), + // GitLab's official Avatar API may return external avatar-service URLs + // such as Gravatar/Libravatar. This is explicit opt-in behavior in the + // source-control settings, so pass through the provider response. + Effect.map((avatar) => { + const avatarUrl = avatar.avatar_url?.trim(); + return avatarUrl && avatarUrl.length > 0 ? avatarUrl : null; + }), + ); + }, createRepository: (input) => { const { namespacePath, projectPath } = parseRepositoryPath(input.repository); const namespaceId: Effect.Effect = namespacePath diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index deca59c48b90..2dfec260fd06 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -124,6 +124,178 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => }), ); +it.effect("sanitizes GitLab list-MR errors before transport", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + listMergeRequests: () => + Effect.fail( + new GitLabCli.GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd: "/repo", + cause: new Error( + "raw upstream https://secret:token@gitlab.example.test/group/repo?access_token=secret", + ), + }), + ), + }); + + const error = yield* provider + .listChangeRequests({ + cwd: "/repo", + headSelector: + "https://user:token@gitlab.example.test/group/repo?access_token=secret#fragment", + state: "open", + }) + .pipe(Effect.flip); + + assert.equal(error.provider, "gitlab"); + assert.equal(error.operation, "listChangeRequests"); + assert.equal(error.command, "glab"); + assert.equal(error.reference, "https://gitlab.example.test/group/repo"); + assert.equal(error.detail, "GitLab CLI command failed."); + assert.deepStrictEqual(error.cause, { + _tag: "GitLabCliCommandError", + name: "GitLabCliCommandError", + command: "glab", + operation: "execute", + detail: "GitLab CLI command failed.", + message: "GitLab CLI failed in execute: GitLab CLI command failed.", + }); + }), +); + +it.effect("lists GitLab MRs against the requested remote repository context", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + listMergeRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + context: { + provider: { + kind: "gitlab", + name: "GitLab Self-Hosted", + baseUrl: "https://gitlab.example.test", + }, + remoteName: "upstream", + remoteUrl: "https://gitlab.example.test/group/subgroup/repo.git", + }, + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + repository: "https://gitlab.example.test/group/subgroup/repo", + state: "all", + limit: 10, + }); + }), +); + +it.effect("does not duplicate a self-hosted GitLab base path", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + listMergeRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + context: { + provider: { + kind: "gitlab", + name: "GitLab Self-Hosted", + baseUrl: "https://gitlab.example.test/gitlab", + }, + remoteName: "upstream", + remoteUrl: "https://gitlab.example.test/gitlab/group/repo.git", + }, + headSelector: "feature/provider", + state: "open", + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + repository: "https://gitlab.example.test/gitlab/group/repo", + state: "open", + }); + }), +); + +it.effect("uses GitLab Avatar API results for commit author avatars", () => + Effect.gen(function* () { + let avatarInput: Parameters[0] | null = + null; + const provider = yield* makeProvider({ + getCommitAvatarUrl: (input) => { + avatarInput = input; + return Effect.succeed( + "https://gitlab.example.test/uploads/-/system/user/avatar/123/avatar.png", + ); + }, + }); + + const avatarUrl = yield* provider.getCommitAvatarUrl({ + cwd: "/repo", + context: { + provider: { + kind: "gitlab", + name: "GitLab Self-Hosted", + baseUrl: "https://gitlab.example.test", + }, + remoteName: "upstream", + remoteUrl: "https://gitlab.example.test/group/subgroup/repo.git", + }, + sha: "abc123", + authorEmail: "author@example.test", + }); + + assert.strictEqual( + avatarUrl, + "https://gitlab.example.test/uploads/-/system/user/avatar/123/avatar.png", + ); + assert.deepStrictEqual(avatarInput, { + cwd: "/repo", + email: "author@example.test", + providerBaseUrl: "https://gitlab.example.test", + }); + }), +); + +it.effect("does not call the GitLab Avatar API without a commit author email", () => + Effect.gen(function* () { + let avatarLookupCount = 0; + const provider = yield* makeProvider({ + getCommitAvatarUrl: () => { + avatarLookupCount += 1; + return Effect.succeed("https://gitlab.example.test/avatar.png"); + }, + }); + + const avatarUrl = yield* provider.getCommitAvatarUrl({ + cwd: "/repo", + sha: "abc123", + authorEmail: null, + }); + + assert.strictEqual(avatarUrl, null); + assert.strictEqual(avatarLookupCount, 0); + }), +); + it.effect("creates GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { let createInput: Parameters[0] | null = diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 00753bef7dea..462d8cbf0f5d 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -96,6 +96,29 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn } as const; } +function repositoryFromContext( + context: SourceControlProvider.SourceControlProviderContext | undefined, +): string | undefined { + if (!context) return undefined; + const repository = SourceControlProvider.repositoryPathFromRemoteUrl(context.remoteUrl); + if (!repository) return undefined; + try { + const baseUrl = new URL(context.provider.baseUrl); + if (baseUrl.hostname.toLowerCase() === "gitlab.com") return repository; + const basePath = decodeURIComponent(baseUrl.pathname).replace(/^\/+|\/+$/gu, ""); + const relativeRepository = + basePath && (repository === basePath || repository.startsWith(`${basePath}/`)) + ? repository.slice(basePath.length).replace(/^\/+/u, "") + : repository; + baseUrl.pathname = [basePath, relativeRepository].filter(Boolean).join("/"); + baseUrl.search = ""; + baseUrl.hash = ""; + return baseUrl.toString(); + } catch { + return repository; + } +} + export const discovery = { type: "cli", kind: "gitlab", @@ -165,30 +188,29 @@ export const make = Effect.gen(function* () { ); }, listChangeRequests: (input) => { - const source = SourceControlProvider.sourceControlRefFromInput(input); + const source = input.headSelector + ? SourceControlProvider.sourceControlRefFromInput({ headSelector: input.headSelector }) + : undefined; + const repository = repositoryFromContext(input.context); return gitlab .listMergeRequests({ cwd: input.cwd, - headSelector: input.headSelector, + ...(input.headSelector ? { headSelector: input.headSelector } : {}), ...(source ? { source } : {}), + ...(repository ? { repository } : {}), state: input.state, ...(input.limit !== undefined ? { limit: input.limit } : {}), }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "gitlab", - operation: "listChangeRequests", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "gitlab", + operation: "listChangeRequests", + cwd: input.cwd, + ...(input.headSelector ? { reference: input.headSelector } : {}), + error, + }), ), ); }, @@ -256,6 +278,30 @@ export const make = Effect.gen(function* () { }), ), ), + getCommitAvatarUrl: (input) => { + const authorEmail = input.authorEmail?.trim(); + if (!authorEmail) { + return Effect.succeed(null); + } + + return gitlab + .getCommitAvatarUrl({ + cwd: input.cwd, + email: authorEmail, + providerBaseUrl: input.context?.provider.baseUrl ?? "https://gitlab.com", + }) + .pipe( + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "gitlab", + operation: "getCommitAvatarUrl", + cwd: input.cwd, + reference: input.sha, + error, + }), + ), + ); + }, createRepository: (input) => gitlab.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/SourceControlPanelActions.ts b/apps/server/src/sourceControl/SourceControlPanelActions.ts new file mode 100644 index 000000000000..4f4985163645 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelActions.ts @@ -0,0 +1,1080 @@ +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import type * as PlatformError from "effect/PlatformError"; +import { + GitCommandError, + type VcsPanelCompareInput, + type VcsPanelCompareResult, + type VcsPanelStashInput, +} from "@t3tools/contracts"; + +import type { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { sanitizeErrorCause } from "../diagnostics/ErrorCause.ts"; +import type { ExecuteGitProgress } from "../vcs/GitVcsDriver.ts"; +import { + parseRemoteNames, + parseRemoteNamesInGitOrder, + parseRemoteRefWithRemoteNames, +} from "../git/remoteRefs.ts"; +import { + parsePathLines, + parseWorktreeBranchEntries, + uniquePaths, +} from "./SourceControlPanelParsers.ts"; +import type { SourceControlPanelService } from "./SourceControlPanelService.ts"; + +export type SourceControlPanelActionMethodName = + | "stageFiles" + | "unstageFiles" + | "discardFiles" + | "readFileDiff" + | "commitStaged" + | "pullBranch" + | "pushBranch" + | "deleteBranch" + | "undoLatestCommit" + | "revertCommit" + | "checkoutCommit" + | "createBranchFromCommit" + | "mergeBranchIntoCurrent" + | "rebaseCurrentOnto" + | "fetchBranch" + | "fetchRemote" + | "addRemote" + | "removeRemote" + | "createStash" + | "applyStash" + | "popStash" + | "dropStash" + | "compare"; + +export const SOURCE_CONTROL_PANEL_REF_AFFECTING_ACTION_METHODS = [ + "commitStaged", + "pullBranch", + "pushBranch", + "deleteBranch", + "undoLatestCommit", + "revertCommit", + "checkoutCommit", + "createBranchFromCommit", + "mergeBranchIntoCurrent", + "rebaseCurrentOnto", + "fetchBranch", + "fetchRemote", + "addRemote", + "removeRemote", +] as const satisfies readonly SourceControlPanelActionMethodName[]; + +interface RunOptions { + readonly stdin?: string; + readonly allowNonZeroExit?: boolean; + readonly env?: NodeJS.ProcessEnv; + readonly progress?: ExecuteGitProgress; +} +type Run = ( + operation: string, + cwd: string, + args: readonly string[], + options?: RunOptions, +) => Effect.Effect; +type TemporaryIndex = ( + input: { + readonly cwd: string; + readonly paths: readonly string[]; + readonly operations: { + readonly gitIndexPath: string; + readonly tempIndexReadTree: string; + readonly tempIndexIntentToAdd: string; + }; + }, + use: (env: NodeJS.ProcessEnv) => Effect.Effect, +) => Effect.Effect; +type SelectedIndex = ( + cwd: string, + paths: readonly string[], + use: (env: NodeJS.ProcessEnv) => Effect.Effect, +) => Effect.Effect; + +export interface SourceControlPanelActionDependencies { + readonly invalidateRefs: (cwd: string) => Effect.Effect; + readonly run: Run; + readonly withTemporaryIntentToAddIndex: TemporaryIndex; + readonly withTemporarySelectedIndex: SelectedIndex; + readonly withStashMutation: ( + cwd: string, + effect: Effect.Effect, + ) => Effect.Effect; + readonly generatedCommitMessage: ( + cwd: string, + paths?: readonly string[], + env?: NodeJS.ProcessEnv, + ) => Effect.Effect; + readonly generatedStashMessage: ( + cwd: string, + mode: "all" | "staged" | "unstaged", + paths: readonly string[], + ) => Effect.Effect; + readonly upstreamForRef: ( + cwd: string, + refName: string, + ) => Effect.Effect; + readonly refExists: ( + operation: string, + cwd: string, + refName: string, + ) => Effect.Effect; + readonly snapshot: SourceControlPanelService["Service"]["snapshot"]; + readonly workflow: GitWorkflowService["Service"]; +} + +const COMMIT_HOOK_NATIVE_DEPENDENCY_FAILURE_DETAIL = + "The Git pre-commit hook could not load a required native dependency. Reinstall the repository dependencies and try again."; +const COMMIT_HOOK_FAILURE_DETAIL = + "The Git pre-commit hook failed. Run the repository pre-commit hook in a terminal for details."; +const REVIEW_DIFF_PATCH_ARGS = [ + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", +] as const; +const REVIEW_DIFF_MINIMAL_PATCH_ARGS = [...REVIEW_DIFF_PATCH_ARGS, "--minimal"] as const; +type CommitFailureHint = "hook-failed" | "native-dependency"; + +function commitFailureHintFromOutputLine(line: string): CommitFailureHint | null { + if ( + line.includes("Cannot find native binding") || + line.includes("Cannot find module 'vite-plus/binding'") || + line.includes('Cannot find module "vite-plus/binding"') + ) + return "native-dependency"; + return line.includes("VITE+ - pre-commit script failed") ? "hook-failed" : null; +} + +function commitFailureDetail(hint: CommitFailureHint | null): string | null { + return hint === "native-dependency" + ? COMMIT_HOOK_NATIVE_DEPENDENCY_FAILURE_DETAIL + : hint === "hook-failed" + ? COMMIT_HOOK_FAILURE_DETAIL + : null; +} + +/** + * Maps known `git branch -d` stderr lines to stable details. Only the branch name and the + * worktree path Git names are copied into the message; unmatched stderr never reaches clients. + */ +function localBranchDeleteFailureDetail( + branchName: string, + stderrLines: readonly string[], +): string | null { + for (const line of stderrLines) { + if (/ is not fully merged/i.test(line)) { + return `Branch ${branchName} is not fully merged. Force delete it to discard its unmerged commits.`; + } + const worktreePath = /(?:used by worktree at|checked out at) '(.+)'/i.exec(line)?.[1]; + if (worktreePath) { + return `Branch ${branchName} is still used by the worktree at ${worktreePath}. Remove that worktree before deleting the branch.`; + } + } + return null; +} + +function commandLabel(args: readonly string[]): string { + return `git ${args.join(" ")}`; +} + +function gitError( + operation: string, + cwd: string, + args: readonly string[], + detail: string, + cause?: unknown, +) { + return new GitCommandError({ + operation, + cwd, + command: commandLabel(args), + detail, + ...(cause === undefined ? {} : { cause }), + }); +} + +const isGitCommandError = Schema.is(GitCommandError); + +function asGitCommandError(operation: string, cwd: string, args: readonly string[]) { + return (cause: unknown) => + isGitCommandError(cause) + ? cause + : gitError(operation, cwd, args, "Git command failed.", sanitizeErrorCause(cause)); +} + +function validateGitPositionalName(input: { + readonly operation: string; + readonly cwd: string; + readonly args: readonly string[]; + readonly kind: string; + readonly value: string; +}): Effect.Effect { + const value = input.value.trim(); + if (value.length === 0) + return Effect.fail( + gitError(input.operation, input.cwd, input.args, `${input.kind} is required.`), + ); + if (value.startsWith("-")) + return Effect.fail( + gitError(input.operation, input.cwd, input.args, `${input.kind} cannot start with "-".`), + ); + return Effect.succeed(value); +} + +function targetRef(target: VcsPanelCompareInput["left"]): string { + switch (target.kind) { + case "working-tree": + return ""; + case "branch": + return target.refName; + case "stash": + return target.refName; + } +} + +function resolveRemoteBranchRef(refName: string, remoteNamesByLength: readonly string[]) { + return parseRemoteRefWithRemoteNames(refName, remoteNamesByLength); +} + +export function makeSourceControlPanelActions( + deps: SourceControlPanelActionDependencies, +): Pick { + const { + generatedCommitMessage, + generatedStashMessage, + invalidateRefs, + refExists, + run, + snapshot, + upstreamForRef, + withTemporaryIntentToAddIndex, + withTemporarySelectedIndex, + withStashMutation, + workflow, + } = deps; + const withRefInvalidation = ( + cwd: string, + effect: Effect.Effect, + ): Effect.Effect => effect.pipe(Effect.ensuring(invalidateRefs(cwd))); + const validateOperand = (operation: string, cwd: string, value: string) => + validateGitPositionalName({ + operation, + cwd, + args: [], + kind: "Git revision or remote", + value, + }); + + const stageFiles: SourceControlPanelService["Service"]["stageFiles"] = (input) => + run( + "vcs.panel.stageFiles", + input.cwd, + ["--literal-pathspecs", "add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"], + { stdin: `${input.paths.join("\0")}\0` }, + ).pipe(Effect.asVoid); + + const unstageFiles: SourceControlPanelService["Service"]["unstageFiles"] = (input) => + run("vcs.panel.unstageFiles", input.cwd, [ + "--literal-pathspecs", + "reset", + "--", + ...input.paths, + ]).pipe(Effect.asVoid); + + const discardFiles: SourceControlPanelService["Service"]["discardFiles"] = (input) => + Effect.gen(function* () { + const paths = uniquePaths(input.paths); + if (paths.length === 0) return; + if (input.staged) { + const headPaths = yield* run( + "vcs.panel.discardStagedFiles.listHeadPaths", + input.cwd, + ["--literal-pathspecs", "ls-tree", "-r", "--name-only", "HEAD", "--", ...paths], + { allowNonZeroExit: true }, + ).pipe(Effect.map(parsePathLines)); + const headPathSet = new Set(headPaths); + const pathsInHead = paths.filter((path) => headPathSet.has(path)); + const pathsOutsideHead = paths.filter((path) => !headPathSet.has(path)); + + if (pathsInHead.length > 0) { + yield* run("vcs.panel.discardStagedFiles", input.cwd, [ + "--literal-pathspecs", + "restore", + "--staged", + "--worktree", + "--source=HEAD", + "--", + ...pathsInHead, + ]).pipe(Effect.asVoid); + } + if (pathsOutsideHead.length > 0) { + yield* run("vcs.panel.discardStagedFiles.reset", input.cwd, [ + "--literal-pathspecs", + "reset", + "--", + ...pathsOutsideHead, + ]).pipe(Effect.asVoid); + yield* run("vcs.panel.discardStagedFiles.clean", input.cwd, [ + "--literal-pathspecs", + "clean", + "-fd", + "--", + ...pathsOutsideHead, + ]).pipe(Effect.asVoid); + } + return; + } + + const trackedPaths = yield* run("vcs.panel.discardUnstagedFiles.listIndexPaths", input.cwd, [ + "--literal-pathspecs", + "ls-files", + "--cached", + "--", + ...paths, + ]).pipe(Effect.map(parsePathLines)); + if (trackedPaths.length > 0) { + yield* run("vcs.panel.discardUnstagedFiles", input.cwd, [ + "--literal-pathspecs", + "restore", + "--worktree", + "--", + ...trackedPaths, + ]).pipe(Effect.asVoid); + } + yield* run("vcs.panel.cleanUntrackedFiles", input.cwd, [ + "--literal-pathspecs", + "clean", + "-fd", + "--", + ...paths, + ]).pipe(Effect.asVoid); + }); + + const readFileDiff: SourceControlPanelService["Service"]["readFileDiff"] = Effect.fn( + "readFileDiff", + )(function* (input) { + const source = input.source ?? { + kind: "working-tree" as const, + staged: input.staged ?? false, + }; + const diffPaths = uniquePaths( + input.originalPath ? [input.originalPath, input.path] : [input.path], + ); + if (source.kind === "commit") { + yield* validateOperand("vcs.panel.readCommitFileDiff", input.cwd, source.sha); + const patch = yield* run("vcs.panel.readCommitFileDiff", input.cwd, [ + "show", + "--format=", + ...REVIEW_DIFF_MINIMAL_PATCH_ARGS, + source.sha, + "--", + ...diffPaths, + ]); + return { path: input.path, staged: false, patch }; + } + if (source.kind === "compare") { + yield* validateOperand("vcs.panel.readCompareFileDiff", input.cwd, source.baseRef); + yield* validateOperand("vcs.panel.readCompareFileDiff", input.cwd, source.refName); + const patch = yield* run("vcs.panel.readCompareFileDiff", input.cwd, [ + "diff", + ...REVIEW_DIFF_MINIMAL_PATCH_ARGS, + `${source.baseRef}...${source.refName}`, + "--", + ...diffPaths, + ]); + return { path: input.path, staged: false, patch }; + } + if (source.kind === "stash") { + yield* validateOperand("vcs.panel.readStashFileDiff", input.cwd, source.stashRef); + let patch = yield* run("vcs.panel.readStashFileDiff", input.cwd, [ + "diff", + ...REVIEW_DIFF_MINIMAL_PATCH_ARGS, + "--find-renames=20%", + `${source.stashRef}^1`, + source.stashRef, + "--", + ...diffPaths, + ]); + if (patch.trim().length === 0) { + patch = yield* run( + "vcs.panel.readStashUntrackedFileDiff", + input.cwd, + [ + "show", + "--format=", + ...REVIEW_DIFF_MINIMAL_PATCH_ARGS, + `${source.stashRef}^3`, + "--", + ...diffPaths, + ], + { allowNonZeroExit: true }, + ); + } + return { path: input.path, staged: false, patch }; + } + + const args = source.staged + ? [ + "diff", + "--cached", + ...REVIEW_DIFF_MINIMAL_PATCH_ARGS, + "--find-renames=20%", + "--", + ...diffPaths, + ] + : ["diff", ...REVIEW_DIFF_MINIMAL_PATCH_ARGS, "--find-renames=20%", "--", ...diffPaths]; + let patch = + !source.staged && input.originalPath + ? yield* withTemporaryIntentToAddIndex( + { + cwd: input.cwd, + paths: [input.path], + operations: { + gitIndexPath: "vcs.panel.readFileDiff.gitIndexPath", + tempIndexReadTree: "vcs.panel.readFileDiff.tempIndexReadTree", + tempIndexIntentToAdd: "vcs.panel.readFileDiff.tempIndexIntentToAdd", + }, + }, + (env) => run("vcs.panel.readFileDiff", input.cwd, args, { env }), + ).pipe(Effect.catch(() => run("vcs.panel.readFileDiff", input.cwd, args))) + : yield* run("vcs.panel.readFileDiff", input.cwd, args); + if (!source.staged && !input.originalPath && patch.trim().length === 0) { + const untrackedPaths = yield* run("vcs.panel.readFileDiff.untrackedPaths", input.cwd, [ + "ls-files", + "--others", + "--exclude-standard", + "-z", + "--", + input.path, + ]); + if (untrackedPaths.split("\0").includes(input.path)) { + patch = yield* run( + "vcs.panel.readUntrackedFileDiff", + input.cwd, + ["diff", "--no-index", ...REVIEW_DIFF_PATCH_ARGS, "--", "/dev/null", input.path], + { allowNonZeroExit: true }, + ); + } + } + return { path: input.path, staged: source.staged, patch }; + }); + + const pushBranchDirect = Effect.fn("pushBranchDirect")(function* ( + cwd: string, + rawBranchName: string, + force: boolean, + rawPublishRemoteName?: string, + ) { + const branchName = yield* validateOperand("vcs.panel.pushBranch", cwd, rawBranchName); + const publishRemoteName = + rawPublishRemoteName === undefined + ? undefined + : yield* validateOperand("vcs.panel.pushBranch", cwd, rawPublishRemoteName); + const upstream = publishRemoteName ? "" : ((yield* upstreamForRef(cwd, branchName)) ?? ""); + const remoteNames = + upstream.length > 0 + ? yield* run("vcs.panel.pushBranch.remotes", cwd, ["remote"]).pipe( + Effect.map(parseRemoteNames), + Effect.orElseSucceed((): readonly string[] => []), + ) + : []; + const parsedUpstream = + upstream.length > 0 ? parseRemoteRefWithRemoteNames(upstream, remoteNames) : null; + const fallbackUpstreamParts = parsedUpstream ? [] : upstream.split("/"); + const upstreamRemoteName = parsedUpstream?.remoteName ?? fallbackUpstreamParts[0] ?? "origin"; + const upstreamBranchName = + (parsedUpstream?.branchName ?? fallbackUpstreamParts.slice(1).join("/")) || branchName; + const hasSameNameUpstream = upstream.length > 0 && upstreamBranchName === branchName; + const remoteName = publishRemoteName ?? (hasSameNameUpstream ? upstreamRemoteName : "origin"); + const remoteBranchName = hasSameNameUpstream ? upstreamBranchName : branchName; + yield* run("vcs.panel.pushBranch", cwd, [ + "push", + ...(force ? ["--force-with-lease"] : []), + "-u", + remoteName, + `refs/heads/${branchName}:refs/heads/${remoteBranchName}`, + ]).pipe(Effect.asVoid); + }); + + const runCommit = Effect.fn("runCommit")(function* ( + cwd: string, + message: string, + env?: NodeJS.ProcessEnv, + ) { + const args = ["commit", "-m", message] as const; + let failureHint: CommitFailureHint | null = null; + const recordFailureHint = (line: string) => + Effect.sync(() => { + const nextHint = commitFailureHintFromOutputLine(line); + if (nextHint !== null && (nextHint === "native-dependency" || failureHint === null)) { + failureHint = nextHint; + } + }); + + yield* run("vcs.panel.commitStaged", cwd, args, { + ...(env === undefined ? {} : { env }), + progress: { + onStdoutLine: recordFailureHint, + onStderrLine: recordFailureHint, + }, + }).pipe( + Effect.mapError((error) => { + const detail = commitFailureDetail(failureHint); + return detail === null + ? error + : gitError("vcs.panel.commitStaged", cwd, args, detail, error); + }), + Effect.asVoid, + ); + }); + + const commitStaged: SourceControlPanelService["Service"]["commitStaged"] = Effect.fn( + "commitStaged", + )(function* (input) { + const paths = uniquePaths(input.paths ?? []); + if (paths.length > 0) { + yield* withTemporarySelectedIndex(input.cwd, paths, (env) => + Effect.gen(function* () { + // The temporary index already contains only the selected changes. + const message = + input.message?.trim() || (yield* generatedCommitMessage(input.cwd, undefined, env)); + yield* runCommit(input.cwd, message, env).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? Effect.gen(function* () { + // Reconcile before temporary-index cleanup can deliver pending cancellation. + const indexSyncExit = yield* Effect.exit( + run( + "vcs.panel.commitStaged.syncIndex", + input.cwd, + [ + "--literal-pathspecs", + "reset", + "HEAD", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + { stdin: `${paths.join("\0")}\0` }, + ), + ); + if (Exit.isFailure(indexSyncExit)) { + return yield* gitError( + "vcs.panel.commitStaged.syncIndex", + input.cwd, + [ + "--literal-pathspecs", + "reset", + "HEAD", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + "The commit was already created but was not pushed. Do not commit these changes again. Reset only the selected paths with git --literal-pathspecs reset HEAD -- , quoting each pathname for your shell, to reconcile the index. Then push the existing commit if needed.", + sanitizeErrorCause(indexSyncExit.cause), + ); + } + }) + : Effect.void, + ), + ); + }), + ); + } else { + const message = input.message?.trim() || (yield* generatedCommitMessage(input.cwd)); + yield* runCommit(input.cwd, message); + } + if (input.push) { + const status = yield* workflow + .status({ cwd: input.cwd }) + .pipe( + Effect.mapError( + asGitCommandError("vcs.panel.commitStaged.status", input.cwd, ["status"]), + ), + ); + if (!status.refName) { + return yield* gitError( + "vcs.panel.commitStaged.push", + input.cwd, + ["push"], + "Cannot push from detached HEAD.", + ); + } + yield* pushBranchDirect(input.cwd, status.refName, false); + } + }); + + const pullBranch: SourceControlPanelService["Service"]["pullBranch"] = Effect.fn("pullBranch")( + function* (input) { + const status = yield* workflow + .status({ cwd: input.cwd }) + .pipe( + Effect.mapError(asGitCommandError("vcs.panel.pullBranch.status", input.cwd, ["status"])), + ); + if (status.refName !== input.branchName) { + if (input.merge) { + return yield* gitError( + "vcs.panel.pullBranch", + input.cwd, + ["pull", "--no-rebase"], + "Merge sync is only available for the current branch.", + ); + } + const upstream = yield* upstreamForRef(input.cwd, input.branchName); + if (!upstream) { + return yield* gitError( + "vcs.panel.pullBranch", + input.cwd, + ["pull"], + `Branch ${input.branchName} has no upstream.`, + ); + } + const remoteOutput = yield* run("vcs.panel.pullBranch.remotes", input.cwd, ["remote"]); + const resolvedUpstream = resolveRemoteBranchRef(upstream, parseRemoteNames(remoteOutput)); + if (!resolvedUpstream) { + return yield* gitError( + "vcs.panel.pullBranch", + input.cwd, + ["pull"], + `Branch ${input.branchName} has invalid upstream ${upstream}.`, + ); + } + yield* run("vcs.panel.pullBranch.nonCurrent", input.cwd, [ + "fetch", + resolvedUpstream.remoteName, + `${input.force ? "+" : ""}refs/heads/${resolvedUpstream.branchName}:refs/heads/${input.branchName}`, + ]).pipe(Effect.asVoid); + return { + status: "pulled" as const, + refName: input.branchName, + upstreamRef: upstream, + }; + } + if (input.force) { + yield* run("vcs.panel.forcePullBranch", input.cwd, ["fetch"]); + const upstream = yield* run("vcs.panel.forcePullBranch.upstream", input.cwd, [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ]).pipe(Effect.map((value) => value.trim())); + yield* run("vcs.panel.forcePullBranch.reset", input.cwd, [ + "reset", + "--hard", + upstream, + ]).pipe(Effect.asVoid); + return { + status: "pulled" as const, + refName: input.branchName, + upstreamRef: upstream, + }; + } + if (input.merge) { + const upstream = yield* run("vcs.panel.mergePullBranch.upstream", input.cwd, [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ]).pipe(Effect.map((value) => value.trim())); + yield* run("vcs.panel.mergePullBranch", input.cwd, [ + "pull", + "--no-rebase", + "--no-edit", + ]).pipe(Effect.asVoid); + return { + status: "pulled" as const, + refName: input.branchName, + upstreamRef: upstream, + }; + } + return yield* workflow.pullCurrentBranch(input.cwd); + }, + ); + + const pushBranch: SourceControlPanelService["Service"]["pushBranch"] = Effect.fn("pushBranch")( + function* (input) { + yield* pushBranchDirect(input.cwd, input.branchName, input.force ?? false, input.remoteName); + }, + ); + + const fetchBranch: SourceControlPanelService["Service"]["fetchBranch"] = Effect.fn("fetchBranch")( + function* (input) { + const remoteOutput = yield* run("vcs.panel.fetchBranch.remotes", input.cwd, ["remote"]); + const gitOrderRemoteNames = parseRemoteNamesInGitOrder(remoteOutput); + const sortedRemoteNames = parseRemoteNames(remoteOutput); + // Local branches intentionally win over same-named remote refs. + const isLocalBranch = yield* refExists( + "vcs.panel.fetchBranch.localBranch", + input.cwd, + `refs/heads/${input.branchName}`, + ); + const parsedRemoteBranch = isLocalBranch + ? null + : parseRemoteRefWithRemoteNames(input.branchName, sortedRemoteNames); + const isRemoteBranch = parsedRemoteBranch + ? yield* refExists( + "vcs.panel.fetchBranch.remoteBranch", + input.cwd, + `refs/remotes/${parsedRemoteBranch.remoteRef}`, + ) + : false; + const upstream = + isRemoteBranch && parsedRemoteBranch + ? parsedRemoteBranch.remoteRef + : yield* upstreamForRef(input.cwd, input.branchName); + const resolvedUpstream = upstream + ? resolveRemoteBranchRef(upstream, sortedRemoteNames) + : null; + if (upstream && !resolvedUpstream) { + return yield* gitError( + "vcs.panel.fetchBranch", + input.cwd, + ["fetch"], + `Branch ${input.branchName} has invalid upstream ${upstream}.`, + ); + } + const remoteName = resolvedUpstream?.remoteName ?? gitOrderRemoteNames[0] ?? "origin"; + const remoteBranchName = resolvedUpstream?.branchName ?? input.branchName; + yield* run("vcs.panel.fetchBranch", input.cwd, [ + "fetch", + remoteName, + `refs/heads/${remoteBranchName}:refs/remotes/${remoteName}/${remoteBranchName}`, + ]).pipe(Effect.asVoid); + }, + ); + + const deleteLocalBranch = Effect.fn("deleteLocalBranch")(function* ( + cwd: string, + branchName: string, + force: boolean, + ) { + const args = ["branch", force ? "-D" : "-d", branchName] as const; + const stderrLines: string[] = []; + yield* run("vcs.panel.deleteLocalBranch", cwd, args, { + progress: { + onStderrLine: (line) => + Effect.sync(() => { + stderrLines.push(line); + }), + }, + }).pipe( + Effect.mapError((error) => { + const detail = localBranchDeleteFailureDetail(branchName, stderrLines); + return detail === null + ? error + : gitError("vcs.panel.deleteLocalBranch", cwd, args, detail, error); + }), + Effect.asVoid, + ); + }); + + const deleteBranch: SourceControlPanelService["Service"]["deleteBranch"] = Effect.fn( + "deleteBranch", + )(function* (input) { + const panelSnapshot = yield* snapshot({ cwd: input.cwd }); + const localBranch = + input.remoteName === undefined + ? panelSnapshot.localBranches.find((branch) => branch.name === input.branchName) + : undefined; + if (localBranch?.current) { + return yield* gitError( + "vcs.panel.deleteBranch", + input.cwd, + ["branch", "-d", input.branchName], + "Cannot delete the current branch.", + ); + } + if (localBranch?.worktreePath) { + return yield* gitError( + "vcs.panel.deleteBranch", + input.cwd, + ["branch", "-d", input.branchName], + "Cannot delete a branch that is checked out in another worktree.", + ); + } + if (localBranch) { + // A worktree whose directory was deleted without `git worktree remove` still owns the + // branch; the snapshot hides that path because the directory is gone, so read Git's own + // registration and prune it before Git refuses the delete. + const registeredWorktree = parseWorktreeBranchEntries( + yield* run( + "vcs.panel.deleteBranch.worktrees", + input.cwd, + ["worktree", "list", "--porcelain"], + { allowNonZeroExit: true }, + ), + ).find((entry) => entry.branchName === input.branchName); + if (registeredWorktree?.prunable) { + yield* run("vcs.panel.deleteBranch.pruneWorktrees", input.cwd, ["worktree", "prune"]); + } + yield* deleteLocalBranch(input.cwd, input.branchName, input.force === true); + return; + } + const remote = + input.remoteName === undefined + ? undefined + : panelSnapshot.remotes.find((candidate) => candidate.name === input.remoteName); + const remoteBranch = remote?.branches.find((branch) => branch.fullRefName === input.branchName); + if (remote && remoteBranch) { + yield* run("vcs.panel.deleteRemoteBranch", input.cwd, [ + "push", + remote.name, + "--delete", + remoteBranch.name, + ]).pipe(Effect.asVoid); + return; + } + return yield* gitError( + "vcs.panel.deleteBranch", + input.cwd, + ["branch", input.force ? "-D" : "-d", input.branchName], + `Branch ${input.branchName} was not found in the current source-control snapshot.`, + ); + }); + + const undoLatestCommit: SourceControlPanelService["Service"]["undoLatestCommit"] = Effect.fn( + "undoLatestCommit", + )(function* (input) { + if (input.sha !== undefined) + yield* validateOperand("vcs.panel.undoLatestCommit", input.cwd, input.sha); + if (input.branchName !== undefined) + yield* validateOperand("vcs.panel.undoLatestCommit", input.cwd, input.branchName); + const currentBranch = yield* run("vcs.panel.currentBranch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.map((branch) => branch.trim())); + const targetBranch = input.branchName ?? currentBranch; + const resetTarget = input.sha ? `${input.sha}^` : `${targetBranch || "HEAD"}~1`; + + if (!targetBranch || targetBranch === currentBranch) { + yield* run("vcs.panel.undoLatestCommit", input.cwd, ["reset", "--soft", resetTarget]).pipe( + Effect.asVoid, + ); + return; + } + + yield* run("vcs.panel.undoBranchCommit", input.cwd, [ + "branch", + "-f", + targetBranch, + resetTarget, + ]).pipe(Effect.asVoid); + }); + + const revertCommit: SourceControlPanelService["Service"]["revertCommit"] = Effect.fn( + "revertCommit", + )(function* (input) { + yield* validateOperand("vcs.panel.revertCommit", input.cwd, input.sha); + yield* run("vcs.panel.revertCommit", input.cwd, ["revert", "--no-edit", input.sha]).pipe( + Effect.asVoid, + ); + }); + + const checkoutCommit: SourceControlPanelService["Service"]["checkoutCommit"] = Effect.fn( + "checkoutCommit", + )(function* (input) { + yield* validateOperand("vcs.panel.checkoutCommit", input.cwd, input.sha); + yield* run("vcs.panel.checkoutCommit", input.cwd, ["checkout", "--detach", input.sha]).pipe( + Effect.asVoid, + ); + return { refName: input.sha }; + }); + + const createBranchFromCommit: SourceControlPanelService["Service"]["createBranchFromCommit"] = + Effect.fn("createBranchFromCommit")(function* (input) { + const branchName = yield* validateGitPositionalName({ + operation: "vcs.panel.createBranchFromCommit", + cwd: input.cwd, + args: ["branch", "", input.sha], + kind: "Branch name", + value: input.branchName ?? "", + }); + yield* withRefInvalidation( + input.cwd, + run("vcs.panel.createBranchFromCommit", input.cwd, [ + "branch", + "--", + branchName, + input.sha, + ]).pipe(Effect.asVoid), + ); + return { refName: branchName }; + }); + + const mergeBranchIntoCurrent: SourceControlPanelService["Service"]["mergeBranchIntoCurrent"] = ( + input, + ) => + run("vcs.panel.mergeBranchIntoCurrent", input.cwd, [ + "merge", + "--no-edit", + "--", + input.refName, + ]).pipe(Effect.asVoid); + + const rebaseCurrentOnto: SourceControlPanelService["Service"]["rebaseCurrentOnto"] = (input) => + run("vcs.panel.rebaseCurrentOnto", input.cwd, ["rebase", "--", input.refName]).pipe( + Effect.asVoid, + ); + + const refAffectingActions = { + commitStaged: (input) => withRefInvalidation(input.cwd, commitStaged(input)), + pullBranch: (input) => withRefInvalidation(input.cwd, pullBranch(input)), + pushBranch: (input) => withRefInvalidation(input.cwd, pushBranch(input)), + deleteBranch: (input) => withRefInvalidation(input.cwd, deleteBranch(input)), + undoLatestCommit: (input) => withRefInvalidation(input.cwd, undoLatestCommit(input)), + revertCommit: (input) => withRefInvalidation(input.cwd, revertCommit(input)), + checkoutCommit: (input) => withRefInvalidation(input.cwd, checkoutCommit(input)), + createBranchFromCommit, + mergeBranchIntoCurrent: (input) => + withRefInvalidation(input.cwd, mergeBranchIntoCurrent(input)), + rebaseCurrentOnto: (input) => withRefInvalidation(input.cwd, rebaseCurrentOnto(input)), + fetchBranch: (input) => withRefInvalidation(input.cwd, fetchBranch(input)), + fetchRemote: (input) => + withRefInvalidation( + input.cwd, + validateOperand("vcs.panel.fetchRemote", input.cwd, input.remoteName).pipe( + Effect.flatMap((remoteName) => + run("vcs.panel.fetchRemote", input.cwd, ["fetch", remoteName]), + ), + Effect.asVoid, + ), + ), + addRemote: Effect.fn("addRemote")(function* (input) { + const remoteName = yield* validateGitPositionalName({ + operation: "vcs.panel.addRemote", + cwd: input.cwd, + args: ["remote", "add", "", input.url], + kind: "Remote name", + value: input.name, + }); + yield* withRefInvalidation( + input.cwd, + run("vcs.panel.addRemote", input.cwd, ["remote", "add", remoteName, input.url]).pipe( + Effect.asVoid, + ), + ); + }), + removeRemote: Effect.fn("removeRemote")(function* (input) { + const remoteName = yield* validateGitPositionalName({ + operation: "vcs.panel.removeRemote", + cwd: input.cwd, + args: ["remote", "remove", ""], + kind: "Remote name", + value: input.remoteName, + }); + yield* withRefInvalidation( + input.cwd, + run("vcs.panel.removeRemote", input.cwd, ["remote", "remove", remoteName]).pipe( + Effect.asVoid, + ), + ); + }), + } satisfies Pick< + SourceControlPanelService["Service"], + (typeof SOURCE_CONTROL_PANEL_REF_AFFECTING_ACTION_METHODS)[number] + >; + + const mutateStash = Effect.fn("mutateStash")(function* ( + action: "apply" | "pop" | "drop", + input: VcsPanelStashInput, + ) { + const operation = `vcs.panel.${action}Stash`; + const stashRef = yield* validateOperand(operation, input.cwd, input.stashRef ?? "stash@{0}"); + yield* withStashMutation( + input.cwd, + Effect.gen(function* () { + if (!input.expectedSha) { + yield* run(operation, input.cwd, ["stash", action, stashRef]); + return; + } + const verify = Effect.fnUntraced(function* (applied = false) { + const sha = (yield* run( + operation, + input.cwd, + ["rev-parse", "--verify", `${stashRef}^{commit}`], + { allowNonZeroExit: true }, + )).trim(); + if (sha.toLowerCase() !== input.expectedSha?.toLowerCase()) { + return yield* gitError( + operation, + input.cwd, + ["stash", action, stashRef], + applied + ? "The stash changes were applied, but this operation did not drop the stash because its position changed. Refresh Source Control before dropping it; do not apply it again." + : "The selected stash has changed. Refresh Source Control and select the stash again.", + ); + } + return sha; + }); + const sha = yield* verify(); + if (action === "apply" || action === "pop") { + yield* run(operation, input.cwd, ["stash", "apply", sha]); + } + if (action === "pop") yield* verify(true); + if (action === "drop" || action === "pop") { + yield* run(operation, input.cwd, ["stash", "drop", stashRef]); + } + }), + ); + }); + + return { + stageFiles, + unstageFiles, + discardFiles, + readFileDiff, + ...refAffectingActions, + createStash: (input) => { + const mode = input.mode ?? "all"; + const modeArgs = + mode === "staged" + ? ["--staged"] + : mode === "unstaged" || input.includeUntracked + ? ["--include-untracked", ...(mode === "unstaged" ? ["--keep-index"] : [])] + : []; + return Effect.gen(function* () { + const paths = input.paths ?? []; + const pathArgs = paths.length > 0 ? ["--", ...paths] : []; + const message = + input.message?.trim() || (yield* generatedStashMessage(input.cwd, mode, paths)); + yield* run("vcs.panel.createStash", input.cwd, [ + ...(paths.length > 0 ? ["--literal-pathspecs"] : []), + "stash", + "push", + ...modeArgs, + "-m", + message, + ...pathArgs, + ]).pipe(Effect.asVoid, (effect) => withStashMutation(input.cwd, effect)); + }); + }, + applyStash: (input) => mutateStash("apply", input), + popStash: (input) => mutateStash("pop", input), + dropStash: (input) => mutateStash("drop", input), + compare: Effect.fn("compare")(function* (input) { + const left = targetRef(input.left); + const right = targetRef(input.right); + if (input.left.kind !== "working-tree") + yield* validateOperand("vcs.panel.compare", input.cwd, left); + if (input.right.kind !== "working-tree") + yield* validateOperand("vcs.panel.compare", input.cwd, right); + const range = left && right ? `${left}..${right}` : left || right; + const reverse = input.left.kind === "working-tree" && input.right.kind !== "working-tree"; + const args = range + ? ["diff", ...REVIEW_DIFF_MINIMAL_PATCH_ARGS, ...(reverse ? ["--reverse"] : []), range] + : ["diff", ...REVIEW_DIFF_PATCH_ARGS]; + return yield* run("vcs.panel.compare", input.cwd, args).pipe( + Effect.map((patch): VcsPanelCompareResult => ({ patch })), + ); + }), + }; +} diff --git a/apps/server/src/sourceControl/SourceControlPanelFetchPolicy.test.ts b/apps/server/src/sourceControl/SourceControlPanelFetchPolicy.test.ts new file mode 100644 index 000000000000..6453a34775ef --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelFetchPolicy.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + AuthSessionId, + RpcClientId, + type BackgroundPolicySnapshot, + type ClientActivityLease, + type HostPowerSnapshot, +} from "@t3tools/contracts"; +import { getBackgroundActivityPresetSettings } from "@t3tools/shared/backgroundActivitySettings"; +import * as DateTime from "effect/DateTime"; + +import { panelFetchOverridesHostLock } from "./SourceControlPanelFetchPolicy.ts"; + +const NOW = DateTime.makeUnsafe("2026-10-01T22:00:00.000Z"); +const CWD = "/repo"; +const balanced = getBackgroundActivityPresetSettings("balanced"); + +const lockedHost: HostPowerSnapshot = { + source: "electron-main", + idle: "true", + idleSeconds: 900, + locked: "true", + suspended: false, + onBattery: "false", + lowPowerMode: "unknown", + thermalState: "unknown", + stale: false, + updatedAt: NOW, +}; + +const remoteLease: ClientActivityLease = { + sessionId: AuthSessionId.make("session"), + rpcClientId: RpcClientId.make(0), + clientId: "remote-client", + clientKind: "desktop-renderer", + visible: true, + focused: true, + recentlyInteracted: false, + scopes: [{ type: "git-refs", cwd: CWD }], + updatedAt: NOW, + expiresAt: DateTime.add(NOW, { seconds: 45 }), +}; + +const policy = ( + hostPower: Partial = {}, + leases: ReadonlyArray = [remoteLease], +): BackgroundPolicySnapshot => ({ + hostPower: { ...lockedHost, ...hostPower }, + leases, + activeForegroundLeaseCount: leases.length, + activeScopeKeys: [], + shouldRunOpportunisticWork: false, + updatedAt: NOW, +}); + +describe("panelFetchOverridesHostLock", () => { + it("lets a foreground client fetch its panel while the host is locked", () => { + expect(panelFetchOverridesHostLock({ policy: policy(), settings: balanced, cwd: CWD })).toBe( + true, + ); + }); + + it("keeps the lock pause without a foreground lease for that cwd", () => { + const background = { ...remoteLease, focused: false, recentlyInteracted: false }; + const otherRepo = { ...remoteLease, scopes: [{ type: "git-refs" as const, cwd: "/other" }] }; + for (const lease of [background, otherRepo]) { + expect( + panelFetchOverridesHostLock({ policy: policy({}, [lease]), settings: balanced, cwd: CWD }), + ).toBe(false); + } + }); + + it("keeps every non-lock host constraint", () => { + for (const host of [ + { suspended: true }, + { thermalState: "serious" as const }, + { lowPowerMode: "true" as const }, + ]) { + expect( + panelFetchOverridesHostLock({ policy: policy(host), settings: balanced, cwd: CWD }), + ).toBe(false); + } + }); + + it("does not apply when the lock is not what paused the fetch", () => { + for (const host of [{ locked: "false" as const }, { stale: true }]) { + expect( + panelFetchOverridesHostLock({ policy: policy(host), settings: balanced, cwd: CWD }), + ).toBe(false); + } + }); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelFetchPolicy.ts b/apps/server/src/sourceControl/SourceControlPanelFetchPolicy.ts new file mode 100644 index 000000000000..807032802ffc --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelFetchPolicy.ts @@ -0,0 +1,40 @@ +import type { BackgroundPolicySnapshot } from "@t3tools/contracts"; +import type { ResolvedBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; + +/** + * Whether an automatic all-remotes panel fetch may run although the shared + * background policy declined it only because the host is locked. + * + * A host lock signals that nobody is at the machine, but a foreground client + * retaining this cwd's panel demand proves someone is using the environment + * remotely. Every other host and client constraint still pauses the fetch. + */ +export function panelFetchOverridesHostLock(input: { + readonly policy: BackgroundPolicySnapshot; + readonly settings: ResolvedBackgroundActivitySettings; + readonly cwd: string; +}): boolean { + const { policy, settings, cwd } = input; + const host = policy.hostPower; + if (host.stale || host.locked !== "true" || !settings.pauseWhenHostLocked) return false; + if ( + host.suspended || + host.thermalState === "serious" || + host.thermalState === "critical" || + (settings.pauseWhenHostLowPower && host.lowPowerMode === "true") || + (settings.pauseWhenOnBattery && host.onBattery === "true") + ) { + return false; + } + // Snapshot leases are already limited to unexpired ones. + return policy.leases.some( + (lease) => + lease.visible && + (lease.focused || lease.recentlyInteracted) && + !(settings.pauseWhenClientLowPower && lease.lowPowerMode === "true") && + !(settings.pauseWhenOnBattery && lease.batteryState === "unplugged") && + lease.scopes.some( + (scope) => (scope.type === "git-refs" || scope.type === "vcs-status") && scope.cwd === cwd, + ), + ); +} diff --git a/apps/server/src/sourceControl/SourceControlPanelParsers.test.ts b/apps/server/src/sourceControl/SourceControlPanelParsers.test.ts new file mode 100644 index 000000000000..8a49a70de6c9 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelParsers.test.ts @@ -0,0 +1,142 @@ +import { parseCommitsWithStats } from "./SourceControlPanelParsers.ts"; +import { assert, describe, expect, it } from "@effect/vitest"; + +import { + parseCommits, + parseLocalBranches, + parseWorktreeBranchEntries, + parseWorktreeBranchPaths, +} from "./SourceControlPanelParsers.ts"; + +describe("worktree entries", () => { + it("marks registrations whose directory is gone as prunable", () => { + const output = [ + "worktree /repo", + "HEAD abc", + "branch refs/heads/main", + "", + "worktree /repo-pr-66", + "HEAD def", + "branch refs/heads/feature/creator-edit-group", + "prunable gitdir file points to non-existent location", + "", + "worktree /repo-detached", + "HEAD 123", + "detached", + "", + ].join("\n"); + + expect(parseWorktreeBranchEntries(output)).toEqual([ + { branchName: "main", worktreePath: "/repo", prunable: false }, + { branchName: "feature/creator-edit-group", worktreePath: "/repo-pr-66", prunable: true }, + ]); + expect([...parseWorktreeBranchPaths(output)]).toEqual([ + ["main", "/repo"], + ["feature/creator-edit-group", "/repo-pr-66"], + ]); + }); +}); + +describe("SourceControlPanelParsers", () => { + it("preserves upstream identities and counts while reading legacy rows as unknown", () => { + const rows = parseLocalBranches( + [ + "local\t\t/repo\t2026-06-20T12:00:00Z\torigin/main\t[ahead 2]\t.", + "tracker\t\t\t2026-06-20T12:00:00Z\torigin/main\t[behind 3]\torigin", + "legacy-six\t\t\t2026-06-20T12:00:00Z\torigin/main\t", + "legacy-five\t\t2026-06-20T12:00:00Z\torigin/main\t", + ].join("\n"), + new Map(), + "main", + ); + expect(rows.find((row) => row.name === "local")).toMatchObject({ + upstreamName: "origin/main", + upstreamRemoteName: ".", + aheadCount: 2, + }); + expect(rows.find((row) => row.name === "tracker")).toMatchObject({ + upstreamName: "origin/main", + upstreamRemoteName: "origin", + behindCount: 3, + }); + expect( + rows.filter((row) => row.name.startsWith("legacy")).map((row) => row.upstreamRemoteName), + ).toEqual([undefined, undefined]); + }); + + it("preserves empty and tab-containing commit subjects", () => { + expect( + parseCommits( + [ + "full-empty\tshort-empty\tAda\tada@example.test\t2026-07-26T12:00:00Z\t", + "full-tabs\tshort-tabs\tGrace\tgrace@example.test\t2026-07-26T13:00:00Z\tSubject\twith\ttabs", + ].join("\n"), + ), + ).toEqual([ + { + sha: "full-empty", + shortSha: "short-empty", + message: "", + authorName: "Ada", + authorEmail: "ada@example.test", + authorAvatarUrl: null, + authoredAt: "2026-07-26T12:00:00Z", + headRefs: [], + tags: [], + files: [], + }, + { + sha: "full-tabs", + shortSha: "short-tabs", + message: "Subject\twith\ttabs", + authorName: "Grace", + authorEmail: "grace@example.test", + authorAvatarUrl: null, + authoredAt: "2026-07-26T13:00:00Z", + headRefs: [], + tags: [], + files: [], + }, + ]); + }); + + it("still rejects truncated commit records", () => { + expect(parseCommits("full\tshort")).toEqual([]); + }); +}); + +describe("commit shortstats", () => { + it("keeps file counts and line totals without eagerly loading paths", () => { + const header = (sha: string) => + [ + sha, + sha.slice(0, 7), + "Author", + "author@example.test", + "2026-09-09T00:00:00Z", + "Message", + ].join("\t"); + const commits = parseCommitsWithStats( + [ + header("a".repeat(40)), + "", + " 2500 files changed, 5000 insertions(+), 2 deletions(-)", + header("b".repeat(40)), + "", + " 1 file changed, 1 deletion(-)", + header("c".repeat(40)), + "", + " 1 file changed, 0 insertions(+), 0 deletions(-)", + ].join("\n"), + ); + assert.deepStrictEqual( + commits.map((commit) => commit.fileStats), + [ + { fileCount: 2500, insertions: 5000, deletions: 2 }, + { fileCount: 1, insertions: 0, deletions: 1 }, + { fileCount: 1, insertions: 0, deletions: 0 }, + ], + ); + assert.isTrue(commits.every((commit) => commit.files.length === 0)); + }); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelParsers.ts b/apps/server/src/sourceControl/SourceControlPanelParsers.ts new file mode 100644 index 000000000000..08c21b9118d4 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelParsers.ts @@ -0,0 +1,411 @@ +import type { + VcsPanelFileChange, + VcsPanelFileStatus, + VcsPanelRemote, + VcsPanelSnapshotResult, + VcsPanelStash, + VcsRef, +} from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; + +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { parseCount, readNulField, statusFromCode } from "./SourceControlPanelStatusParsers.ts"; + +export interface WorktreeBranchEntry { + readonly branchName: string; + readonly worktreePath: string; + /** Git reports the registered directory as missing, so `git worktree prune` would drop it. */ + readonly prunable: boolean; +} +export function parseRemoteVerbose(output: string): VcsPanelRemote[] { + const byName = new Map(); + for (const line of output.split("\n")) { + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/u.exec(line.trim()); + if (!match) continue; + const [, name, url, direction] = match; + if (!name || !url || !direction) continue; + const current = byName.get(name) ?? { fetchUrl: null, pushUrl: null }; + if (direction === "fetch") current.fetchUrl = url; + if (direction === "push") current.pushUrl = url; + byName.set(name, current); + } + return [...byName.entries()].map(([name, remote]) => ({ + name, + fetchUrl: remote.fetchUrl, + pushUrl: remote.pushUrl, + provider: remote.fetchUrl ? detectSourceControlProviderFromRemoteUrl(remote.fetchUrl) : null, + branches: [], + })); +} + +export function parseRemoteBranches( + output: string, + remoteName: string, +): VcsPanelRemote["branches"] { + const seen = new Set(); + return output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const [name = "", lastActivityAt = ""] = line.split("\t"); + return { + name, + lastActivityAt: lastActivityAt.length > 0 ? lastActivityAt : null, + }; + }) + .filter((branch) => branch.name !== `${remoteName}/HEAD`) + .filter((branch) => branch.name !== remoteName) + .filter((branch) => { + const name = branch.name; + if (seen.has(name)) return false; + seen.add(name); + return true; + }) + .map((branch) => ({ + name: branch.name.startsWith(`${remoteName}/`) + ? branch.name.slice(remoteName.length + 1) + : branch.name, + fullRefName: branch.name, + isDefaultRemoteHead: false, + lastActivityAt: branch.lastActivityAt, + })) + .toSorted(compareBranchActivity); +} + +export function parseStashes(output: string): VcsPanelStash[] { + return output.split("\n").flatMap((line) => { + const [refName, sha, createdAt, message] = line.split("\t"); + if (!refName) return []; + return [ + { + refName, + sha: sha && sha.length > 0 ? sha : null, + createdAt: createdAt && createdAt.length > 0 ? createdAt : null, + message: message && message.trim().length > 0 ? message.trim() : refName, + }, + ]; + }); +} + +export function providerContextForRemote( + remote: VcsPanelRemote, +): SourceControlProvider.SourceControlProviderContext | null { + if (!remote.provider || remote.provider.kind === "unknown" || !remote.fetchUrl) { + return null; + } + return { + provider: remote.provider, + remoteName: remote.name, + remoteUrl: remote.fetchUrl, + }; +} + +export function parseBranchTrackCounts(track: string): { + readonly aheadCount: number; + readonly behindCount: number; +} { + const aheadCount = Number.parseInt(/ahead (\d+)/u.exec(track)?.[1] ?? "0", 10); + const behindCount = Number.parseInt(/behind (\d+)/u.exec(track)?.[1] ?? "0", 10); + return { + aheadCount: Number.isFinite(aheadCount) ? aheadCount : 0, + behindCount: Number.isFinite(behindCount) ? behindCount : 0, + }; +} + +export function parseAheadBehindCounts(output: string): { + readonly aheadCount: number; + readonly behindCount: number; +} { + const [aheadRaw = "0", behindRaw = "0"] = output.trim().split(/\s+/u); + const aheadCount = Number.parseInt(aheadRaw, 10); + const behindCount = Number.parseInt(behindRaw, 10); + return { + aheadCount: Number.isFinite(aheadCount) && aheadCount > 0 ? aheadCount : 0, + behindCount: Number.isFinite(behindCount) && behindCount > 0 ? behindCount : 0, + }; +} + +export function parseWorktreeBranchEntries(output: string): WorktreeBranchEntry[] { + const entries: WorktreeBranchEntry[] = []; + let currentPath: string | null = null; + let currentBranch: string | null = null; + let currentPrunable = false; + + const flush = () => { + if (currentPath !== null && currentBranch !== null) { + entries.push({ + branchName: currentBranch, + worktreePath: currentPath, + prunable: currentPrunable, + }); + } + currentPath = null; + currentBranch = null; + currentPrunable = false; + }; + + for (const rawLine of output.split(/\r?\n/u)) { + const line = rawLine.trimEnd(); + if (line.length === 0) { + flush(); + } else if (line.startsWith("worktree ")) { + flush(); + currentPath = line.slice("worktree ".length); + } else if (line.startsWith("branch refs/heads/")) { + currentBranch = line.slice("branch refs/heads/".length); + } else if (line === "prunable" || line.startsWith("prunable ")) { + currentPrunable = true; + } + } + flush(); + + return entries; +} + +export function parseWorktreeBranchPaths(output: string): Map { + return new Map( + parseWorktreeBranchEntries(output).map((entry) => [entry.branchName, entry.worktreePath]), + ); +} + +export function parseLocalBranches( + output: string, + worktreeBranchPaths: ReadonlyMap = new Map(), + statusDefaultBranchName: string | null = null, +): VcsRef[] { + const rows = output + .split(/\r?\n/u) + .filter((line) => line.trimEnd().length > 0) + .map((line) => { + // Preserve trailing tabs so empty upstream track columns stay aligned. + const columns = line.split("\t"); + const [name = "", head = ""] = columns; + const hasInlineWorktreePath = columns.length >= 6; + const worktreePath = hasInlineWorktreePath ? (columns[2] ?? "") : ""; + const lastActivityAt = hasInlineWorktreePath ? (columns[3] ?? "") : (columns[2] ?? ""); + const upstreamName = hasInlineWorktreePath ? (columns[4] ?? "") : (columns[3] ?? ""); + const track = hasInlineWorktreePath ? (columns[5] ?? "") : (columns[4] ?? ""); + // A configured upstream whose remote ref does not exist was never pushed or + // was deleted; the panel offers to publish such a branch. + const upstreamGone = track.trim() === "[gone]"; + const upstreamRemoteName = upstreamGone ? undefined : columns[6] || undefined; + const { aheadCount, behindCount } = parseBranchTrackCounts(track); + const resolvedWorktreePath = worktreeBranchPaths.get(name) ?? worktreePath; + return { + name, + current: head.trim() === "*", + worktreePath: resolvedWorktreePath.length > 0 ? resolvedWorktreePath : null, + lastActivityAt: lastActivityAt.length > 0 ? lastActivityAt : null, + upstreamName: upstreamName.length > 0 && !upstreamGone ? upstreamName : null, + ...(upstreamRemoteName ? { upstreamRemoteName } : {}), + aheadCount, + behindCount, + }; + }) + .filter((branch) => branch.name.length > 0); + const defaultName = + (statusDefaultBranchName !== null + ? rows.find((branch) => branch.name === statusDefaultBranchName)?.name + : null) ?? + rows.find((branch) => branch.name === "main")?.name ?? + rows.find((branch) => branch.name === "master")?.name ?? + rows.find((branch) => !branch.current)?.name ?? + rows[0]?.name ?? + null; + + return rows + .map((branch) => ({ + name: branch.name, + current: branch.current, + isDefault: branch.name === defaultName, + worktreePath: branch.worktreePath, + lastActivityAt: branch.lastActivityAt, + upstreamName: branch.upstreamName, + ...(branch.upstreamRemoteName ? { upstreamRemoteName: branch.upstreamRemoteName } : {}), + aheadCount: branch.aheadCount, + behindCount: branch.behindCount, + })) + .toSorted(compareBranchActivity); +} + +export function branchActivityTime(value: { + readonly lastActivityAt?: string | null | undefined; +}): number { + if (!value.lastActivityAt) return 0; + const time = Date.parse(value.lastActivityAt); + return Number.isFinite(time) ? time : 0; +} + +export function compareBranchActivity( + left: { readonly lastActivityAt?: string | null; readonly name: string }, + right: { readonly lastActivityAt?: string | null; readonly name: string }, +): number { + const activity = branchActivityTime(right) - branchActivityTime(left); + return activity !== 0 ? activity : left.name.localeCompare(right.name); +} + +export function parsePathLines(output: string): string[] { + return output.split(/\r?\n/u).filter((line) => line.length > 0); +} + +export function uniquePaths(paths: readonly string[]): string[] { + return [...new Set(paths.filter((path) => path.length > 0))]; +} + +export function parseCreatedFromRef(output: string): string | null { + for (const line of output.split(/\r?\n/u)) { + const match = /^branch: Created from (.+)$/u.exec(line.trim()); + const refName = match?.[1]?.trim(); + if (!refName || refName === "HEAD") continue; + return refName.replace(/^refs\/heads\//u, "").replace(/^refs\/remotes\//u, ""); + } + return null; +} + +export function parseCommits(output: string): VcsPanelSnapshotResult["recentCommits"] { + return output.split("\n").flatMap((line) => { + const fields = line.split("\t"); + if (fields.length < 6) return []; + const [sha, shortSha, authorName, authorEmail, authoredAt, ...messageParts] = fields; + if (!sha || !shortSha) return []; + const message = messageParts.join("\t"); + return [ + { + sha, + shortSha, + message, + authorName: authorName ?? null, + authorEmail: authorEmail ?? null, + authorAvatarUrl: null, + authoredAt: authoredAt ?? null, + headRefs: [], + tags: [], + files: [], + }, + ]; + }); +} + +export function fileStatusFromNameStatus(status: string | undefined): VcsPanelFileStatus { + if (!status) return "modified"; + if (status.startsWith("R")) return "renamed"; + if (status.startsWith("C")) return "copied"; + return statusFromCode(status[0] ?? "M", "modified"); +} + +export function parseNameStatus( + output: string, +): Map { + const statuses = new Map(); + if (output.includes("\0")) { + const fields = output.split("\0").filter((field) => field.length > 0); + for (let index = 0; index < fields.length; index += 1) { + const statusRaw = fields[index]; + const firstPath = fields[index + 1]; + if (!statusRaw || !firstPath) continue; + const status = fileStatusFromNameStatus(statusRaw); + const hasSecondPath = statusRaw.startsWith("R") || statusRaw.startsWith("C"); + const secondPath = hasSecondPath ? fields[index + 2] : undefined; + if (hasSecondPath) index += 2; + else index += 1; + const path = secondPath ?? firstPath; + statuses.set(path, { + status, + originalPath: secondPath ? firstPath : null, + }); + } + return statuses; + } + for (const line of output.split("\n")) { + const [statusRaw, firstPath, secondPath] = line.split("\t"); + if (!statusRaw || !firstPath) continue; + const path = secondPath ?? firstPath; + statuses.set(path, { + status: fileStatusFromNameStatus(statusRaw), + originalPath: secondPath ? firstPath : null, + }); + } + return statuses; +} + +export function parseFileChangesFromNumstat(input: { + numstat: string; + statuses?: Map; +}): VcsPanelFileChange[] { + const files: VcsPanelFileChange[] = []; + if (input.numstat.includes("\0")) { + let index = 0; + while (index < input.numstat.length) { + const headerEndIndex = input.numstat.indexOf("\t", index); + if (headerEndIndex < 0) break; + const insertionsRaw = input.numstat.slice(index, headerEndIndex); + const deletionEndIndex = input.numstat.indexOf("\t", headerEndIndex + 1); + if (deletionEndIndex < 0) break; + const deletionsRaw = input.numstat.slice(headerEndIndex + 1, deletionEndIndex); + index = deletionEndIndex + 1; + let pathField = readNulField(input.numstat, index); + index = pathField.nextIndex; + let originalPath: string | null = null; + if (pathField.value === "") { + const originalPathField = readNulField(input.numstat, index); + index = originalPathField.nextIndex; + const renamedPathField = readNulField(input.numstat, index); + index = renamedPathField.nextIndex; + originalPath = originalPathField.value || null; + pathField = renamedPathField; + } + const path = pathField.value; + if (!path) continue; + const status = input.statuses?.get(path); + const resolvedOriginalPath = status?.originalPath ?? originalPath; + files.push({ + path, + originalPath: resolvedOriginalPath, + status: status?.status ?? (resolvedOriginalPath ? "renamed" : "modified"), + insertions: parseCount(insertionsRaw), + deletions: parseCount(deletionsRaw), + }); + } + return files.toSorted((left, right) => left.path.localeCompare(right.path)); + } + for (const line of input.numstat.split("\n")) { + const [insertionsRaw, deletionsRaw, oldPathRaw, newPathRaw] = line.split("\t"); + const path = newPathRaw ?? oldPathRaw; + if (!path) continue; + const status = input.statuses?.get(path); + const originalPath = status?.originalPath ?? (newPathRaw ? (oldPathRaw ?? null) : null); + files.push({ + path, + originalPath, + status: status?.status ?? (originalPath ? "renamed" : "modified"), + insertions: parseCount(insertionsRaw), + deletions: parseCount(deletionsRaw), + }); + } + return files.toSorted((left, right) => left.path.localeCompare(right.path)); +} + +/** Parse Git's C-locale shortstat without shipping a pathname for every changed file. */ +export function parseCommitsWithStats(output: string): VcsPanelSnapshotResult["recentCommits"] { + const stats = new Map(); + let sha = ""; + for (const line of output.split("\n")) { + const fields = line.split("\t"); + if (fields.length >= 6) { + sha = fields[0] ?? ""; + continue; + } + const changed = /^\s+(\d+) files? changed/.exec(line); + if (!changed || !sha) continue; + stats.set(sha, { + fileCount: Number(changed[1]), + insertions: Number(/(\d+) insertions?\(\+\)/.exec(line)?.[1] ?? 0), + deletions: Number(/(\d+) deletions?\(-\)/.exec(line)?.[1] ?? 0), + }); + } + return parseCommits(output).map((commit) => ({ + ...commit, + fileStats: stats.get(commit.sha) ?? { fileCount: 0, insertions: 0, deletions: 0 }, + })); +} diff --git a/apps/server/src/sourceControl/SourceControlPanelReaders.test.ts b/apps/server/src/sourceControl/SourceControlPanelReaders.test.ts new file mode 100644 index 000000000000..8965b6a41ace --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelReaders.test.ts @@ -0,0 +1,472 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ModelSelection, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { PersistenceSqlError } from "../persistence/Errors.ts"; +import type { ServerSettingsService } from "../serverSettings.ts"; +import type * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import { makeSourceControlWritingPolicyResolver } from "../textGeneration/SourceControlWriting.ts"; +import { makeSourceControlPanelReaders } from "./SourceControlPanelReaders.ts"; + +const textGenerationModelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "text-generation-model", + options: [], +}; +const sourceControlWriterModelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "source-control-writer-model", + options: [], +}; + +function makeReaders(input: { + readonly sourceControlWriterModelSelection: ModelSelection | null; + readonly sourceControlWritingStyle: typeof DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle; + readonly onGenerate: (request: TextGeneration.CommitMessageGenerationInput) => void; + readonly recentCommitSubjects?: readonly string[]; + readonly repositoryInstructions?: Readonly>>; + readonly providers?: ReadonlyArray; + readonly projectSettingsOverrides?: typeof DEFAULT_SERVER_SETTINGS.projectSettingsOverrides; + readonly projects?: Readonly>; + readonly workspaceRoot?: string; + readonly mainWorkspaceRoot?: string; + readonly failProjectLookup?: boolean; +}) { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + ...DEFAULT_SERVER_SETTINGS.providerInstances, + ...Object.fromEntries( + (input.providers ?? []).map((provider) => [ + provider.instanceId, + { driver: provider.driver, config: {} }, + ]), + ), + }, + textGenerationModelSelection, + projectSettingsOverrides: input.projectSettingsOverrides ?? {}, + sourceControlWriterModelSelection: input.sourceControlWriterModelSelection, + sourceControlWritingStyle: input.sourceControlWritingStyle, + }; + const providers = + input.providers ?? + (input.sourceControlWriterModelSelection + ? [ + { + instanceId: input.sourceControlWriterModelSelection.instanceId, + driver: + input.sourceControlWriterModelSelection.instanceId === "claudeAgent" + ? ProviderDriverKind.make("claudeAgent") + : ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + } satisfies ServerProvider, + ] + : []); + return makeSourceControlPanelReaders({ + branchPullRequest: () => Effect.succeed(null), + projectIdForWorkspace: (cwd) => + input.failProjectLookup + ? Effect.fail(new PersistenceSqlError({ operation: "test.projectLookup" })) + : Effect.succeed(input.projects?.[cwd] ?? null), + run: (operation) => + Effect.succeed( + operation === "vcs.panel.writingWorkspaceRoot" + ? (input.workspaceRoot ?? "/repo") + : operation === "vcs.panel.writingWorktrees" + ? `worktree ${input.mainWorkspaceRoot ?? "/repo"}\0detached\0\0` + : operation.endsWith("Summary") + ? "1 file changed" + : operation.endsWith("Status") + ? "M src/example.ts" + : "diff --git a/src/example.ts b/src/example.ts", + ), + serverSettings: { + getSettings: Effect.succeed(settings), + } as unknown as ServerSettingsService["Service"], + sourceControlProviders: undefined, + sourceControlRateLimits: undefined, + getProviders: Effect.succeed(providers), + resolveWritingPolicy: makeSourceControlWritingPolicyResolver({ + runGit: () => Effect.succeed((input.recentCommitSubjects ?? []).join("\n")), + readRepositoryInstructions: (_cwd, fileName) => + Effect.succeed(input.repositoryInstructions?.[fileName as "AGENTS.md" | "CLAUDE.md"] ?? ""), + }), + textGeneration: { + generateCommitMessage: (request: TextGeneration.CommitMessageGenerationInput) => + Effect.sync(() => { + input.onGenerate(request); + return { subject: "Generated message", body: "" }; + }), + } as unknown as TextGeneration.TextGeneration["Service"], + }); +} + +describe("SourceControlPanelReaders generated messages", () => { + it.effect("uses registered checkout overrides for commits and stashes", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project"); + const requests: TextGeneration.CommitMessageGenerationInput[] = []; + const projectWriter = { ...sourceControlWriterModelSelection, model: "project-writer" }; + const readers = makeReaders({ + sourceControlWriterModelSelection, + sourceControlWritingStyle: { + mode: "custom", + customInstructions: "Environment style", + followChangeRequestTemplates: true, + }, + projectSettingsOverrides: { + [projectId]: { + sourceControlWriterModelSelection: projectWriter, + sourceControlWritingStyle: { + mode: "custom", + customInstructions: "Project style", + followChangeRequestTemplates: true, + }, + }, + }, + projects: { "/repo": projectId }, + onGenerate: (request) => requests.push(request), + }); + yield* readers.generatedCommitMessage("/repo"); + yield* readers.generatedStashMessage("/repo", "all"); + assert.equal(requests.length, 2); + for (const request of requests) { + assert.deepStrictEqual(request.modelSelection, projectWriter); + assert.equal(request.policy?.commitInstructions, "Project style"); + } + }), + ); + + it.effect("prefers registered sibling overrides from a nested cwd over the main project", () => + Effect.gen(function* () { + const siblingId = ProjectId.make("sibling-project"); + const mainId = ProjectId.make("main-project"); + const requests: TextGeneration.CommitMessageGenerationInput[] = []; + const siblingWriter = { ...sourceControlWriterModelSelection, model: "sibling-writer" }; + const readers = makeReaders({ + sourceControlWriterModelSelection, + sourceControlWritingStyle: DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle, + projectSettingsOverrides: { + [siblingId]: { + sourceControlWriterModelSelection: siblingWriter, + sourceControlWritingStyle: { + mode: "custom", + customInstructions: "Sibling style", + followChangeRequestTemplates: true, + }, + }, + [mainId]: { + sourceControlWriterModelSelection: { + ...sourceControlWriterModelSelection, + model: "main-writer", + }, + sourceControlWritingStyle: { + mode: "custom", + customInstructions: "Main style", + followChangeRequestTemplates: true, + }, + }, + }, + projects: { "/sibling": siblingId, "/repo": mainId }, + workspaceRoot: "/sibling", + mainWorkspaceRoot: "/repo", + onGenerate: (request) => requests.push(request), + }); + yield* readers.generatedCommitMessage("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/sibling/sub"); + yield* readers.generatedStashMessage("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/sibling/sub", "all"); + assert.equal(requests.length, 2); + for (const request of requests) { + assert.deepStrictEqual(request.modelSelection, siblingWriter); + assert.equal(request.policy?.commitInstructions, "Sibling style"); + } + }), + ); + + it.effect("inherits main-project overrides for unregistered sibling worktrees", () => + Effect.gen(function* () { + const projectId = ProjectId.make("main-project"); + let request: TextGeneration.CommitMessageGenerationInput | undefined; + const projectTextWriter = { ...textGenerationModelSelection, model: "project-text-writer" }; + const readers = makeReaders({ + sourceControlWriterModelSelection, + sourceControlWritingStyle: { + mode: "custom", + customInstructions: "Environment style", + followChangeRequestTemplates: true, + }, + projectSettingsOverrides: { + [projectId]: { + sourceControlWriterModelSelection: null, + textGenerationModelSelection: projectTextWriter, + sourceControlWritingStyle: { + mode: "custom", + customInstructions: "Main project style", + followChangeRequestTemplates: true, + }, + }, + }, + projects: { "/repo": projectId }, + workspaceRoot: "/sibling", + mainWorkspaceRoot: "/repo", + onGenerate: (value) => { + request = value; + }, + }); + yield* readers.generatedStashMessage("/sibling", "all"); + assert.deepStrictEqual(request?.modelSelection, projectTextWriter); + assert.equal(request?.policy?.commitInstructions, "Main project style"); + }), + ); + + it.effect("keeps environment generation when project lookup fails", () => + Effect.gen(function* () { + let request: TextGeneration.CommitMessageGenerationInput | undefined; + const readers = makeReaders({ + sourceControlWriterModelSelection, + sourceControlWritingStyle: { + mode: "custom", + customInstructions: "Environment style", + followChangeRequestTemplates: true, + }, + projectSettingsOverrides: { + [ProjectId.make("unrelated")]: { sourceControlWriterModelSelection: null }, + }, + failProjectLookup: true, + onGenerate: (value) => { + request = value; + }, + }); + assert.equal(yield* readers.generatedCommitMessage("/repo"), "Generated message"); + assert.deepStrictEqual(request?.modelSelection, sourceControlWriterModelSelection); + assert.equal(request?.policy?.commitInstructions, "Environment style"); + }), + ); + + it.effect("uses the source control writer model and writing style for commits", () => + Effect.gen(function* () { + let generatedInput: TextGeneration.CommitMessageGenerationInput | undefined; + const readers = makeReaders({ + sourceControlWriterModelSelection, + sourceControlWritingStyle: { + mode: "custom", + customInstructions: "Use the configured source control voice.", + followChangeRequestTemplates: true, + }, + onGenerate: (request) => { + generatedInput = request; + }, + }); + + assert.equal(yield* readers.generatedCommitMessage("/repo"), "Generated message"); + assert.deepStrictEqual(generatedInput?.modelSelection, sourceControlWriterModelSelection); + assert.deepInclude(generatedInput?.policy, { + kind: "custom", + commitInstructions: "Use the configured source control voice.", + inferRepositoryConventions: false, + }); + }), + ); + + it.effect("falls back to the text generation model and keeps writing style for stashes", () => + Effect.gen(function* () { + let generatedInput: TextGeneration.CommitMessageGenerationInput | undefined; + const readers = makeReaders({ + sourceControlWriterModelSelection: null, + sourceControlWritingStyle: { + mode: "conventional_commits", + customInstructions: "", + followChangeRequestTemplates: true, + }, + onGenerate: (request) => { + generatedInput = request; + }, + }); + + assert.equal(yield* readers.generatedStashMessage("/repo", "all"), "Generated message"); + assert.deepStrictEqual(generatedInput?.modelSelection, textGenerationModelSelection); + assert.equal(generatedInput?.policy?.kind, "conventional_commits"); + }), + ); + + it.effect("uses repository instructions for panel commits and stashes", () => + Effect.gen(function* () { + const generatedPolicies: TextGeneration.CommitMessageGenerationInput["policy"][] = []; + const agentInstructions = "Use lowercase source control text."; + const claudeInstructions = "Keep generated messages brief."; + const readers = makeReaders({ + sourceControlWriterModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + options: [], + }, + sourceControlWritingStyle: { + mode: "repo_conventions", + customInstructions: "", + followChangeRequestTemplates: true, + }, + recentCommitSubjects: ["feat: keep the existing subject style"], + repositoryInstructions: { + "AGENTS.md": agentInstructions, + "CLAUDE.md": claudeInstructions, + }, + onGenerate: (request) => { + generatedPolicies.push(request.policy); + }, + }); + + assert.equal(yield* readers.generatedCommitMessage("/repo"), "Generated message"); + assert.equal(yield* readers.generatedStashMessage("/repo", "all"), "Generated message"); + + const repositoryContext = [ + "Recent commit subjects from this repository:\nfeat: keep the existing subject style", + `Local AGENTS.md:\n${agentInstructions}`, + `Local CLAUDE.md:\n${claudeInstructions}`, + ].join("\n\n"); + const expectedPolicy: NonNullable = { + kind: "repo_conventions", + commitInstructions: `Follow the repository's established commit message style when examples are available.\n\n${repositoryContext}`, + changeRequestInstructions: `Follow the repository's established change request title and body style when examples are available.\n\n${repositoryContext}`, + inferRepositoryConventions: true, + }; + assert.deepStrictEqual(generatedPolicies, [expectedPolicy, expectedPolicy]); + }), + ); + + it.effect("excludes Claude instructions for non-Claude panel writers", () => + Effect.gen(function* () { + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"]; + const readers = makeReaders({ + sourceControlWriterModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [], + }, + sourceControlWritingStyle: { + mode: "repo_conventions", + customInstructions: "", + followChangeRequestTemplates: true, + }, + repositoryInstructions: { + "AGENTS.md": "Use repository commit conventions.", + "CLAUDE.md": "Only Claude writers should receive this.", + }, + onGenerate: (request) => { + generatedPolicy = request.policy; + }, + }); + + assert.equal(yield* readers.generatedCommitMessage("/repo"), "Generated message"); + assert.match(generatedPolicy?.commitInstructions ?? "", /Local AGENTS\.md:/); + assert.equal((generatedPolicy?.commitInstructions ?? "").includes("Local CLAUDE.md:"), false); + }), + ); + + it.effect("uses Claude instructions for custom Claude provider instances", () => + Effect.gen(function* () { + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"]; + const customClaudeInstanceId = ProviderInstanceId.make("claude-secondary"); + const readers = makeReaders({ + sourceControlWriterModelSelection: { + instanceId: customClaudeInstanceId, + model: "claude-sonnet-4-6", + options: [], + }, + sourceControlWritingStyle: { + mode: "repo_conventions", + customInstructions: "", + followChangeRequestTemplates: true, + }, + providers: [ + { + instanceId: customClaudeInstanceId, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ], + repositoryInstructions: { + "AGENTS.md": "Use repository commit conventions.", + "CLAUDE.md": "Keep custom Claude messages brief.", + }, + onGenerate: (request) => { + generatedPolicy = request.policy; + }, + }); + + assert.equal(yield* readers.generatedCommitMessage("/repo"), "Generated message"); + assert.match(generatedPolicy?.commitInstructions ?? "", /Local CLAUDE\.md:/); + }), + ); + + it.effect("falls back from unavailable source control writers before resolving policy", () => + Effect.gen(function* () { + let generatedInput: TextGeneration.CommitMessageGenerationInput | undefined; + const unavailableInstanceId = ProviderInstanceId.make("claude-unavailable"); + const readers = makeReaders({ + sourceControlWriterModelSelection: { + instanceId: unavailableInstanceId, + model: "claude-sonnet-4-6", + options: [], + }, + sourceControlWritingStyle: { + mode: "repo_conventions", + customInstructions: "", + followChangeRequestTemplates: true, + }, + providers: [ + { + instanceId: unavailableInstanceId, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + installed: false, + version: null, + status: "disabled", + auth: { status: "unknown" }, + checkedAt: "2026-09-01T00:00:00.000Z", + availability: "unavailable", + unavailableReason: "Claude is not available in this test.", + models: [], + slashCommands: [], + skills: [], + }, + ], + repositoryInstructions: { + "AGENTS.md": "Use repository commit conventions.", + "CLAUDE.md": "Unavailable Claude writers must not receive this.", + }, + onGenerate: (request) => { + generatedInput = request; + }, + }); + + assert.equal(yield* readers.generatedCommitMessage("/repo"), "Generated message"); + assert.deepStrictEqual(generatedInput?.modelSelection, textGenerationModelSelection); + assert.equal( + (generatedInput?.policy?.commitInstructions ?? "").includes("Local CLAUDE.md:"), + false, + ); + }), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelReaders.ts b/apps/server/src/sourceControl/SourceControlPanelReaders.ts new file mode 100644 index 000000000000..1a63277229a3 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelReaders.ts @@ -0,0 +1,987 @@ +import * as Effect from "effect/Effect"; +import type { + ProjectId, + GitCommandError, + VcsPanelActionableForkBranch, + VcsPanelBranchCommitsInput, + VcsPanelBranchCommitsResult, + VcsPanelBranchDetails, + VcsPanelChangeGroup, + VcsPanelSnapshotResult, + VcsPanelRemote, + VcsPanelStashDetails, + VcsRef, + ServerProvider, + SourceControlProviderError, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { + hasProjectSettingsOverrides, + resolveProjectSettings, +} from "@t3tools/shared/projectSettings"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; +import type { GitManager, GitBranchPullRequest } from "../git/GitManager.ts"; +import * as SharedServerSettings from "@t3tools/shared/serverSettings"; + +import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; +import type { ServerSettingsService } from "../serverSettings.ts"; +import type { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import type { SourceControlWritingPolicyResolver } from "../textGeneration/SourceControlWriting.ts"; +import type { ExecuteGitProgress } from "../vcs/GitVcsDriver.ts"; +import type { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; +import { + parseAheadBehindCounts, + branchActivityTime, + parseCommits, + parseCommitsWithStats, + parseCreatedFromRef, + parseFileChangesFromNumstat, + parseNameStatus, + parseRemoteVerbose, + providerContextForRemote, +} from "./SourceControlPanelParsers.ts"; +import { + fileChangesRelativeToCwd, + parseNumstat, + parsePorcelainStatus, +} from "./SourceControlPanelStatusParsers.ts"; + +type ConfiguredSourceControlProviderKind = Exclude; + +function isConfiguredSourceControlProviderKind( + kind: SourceControlProviderKind, +): kind is ConfiguredSourceControlProviderKind { + return kind !== "unknown"; +} + +type Run = ( + operation: string, + cwd: string, + args: readonly string[], + options?: { + readonly allowNonZeroExit?: boolean; + readonly env?: NodeJS.ProcessEnv; + readonly progress?: ExecuteGitProgress; + }, +) => Effect.Effect; + +export interface SourceControlPanelReaderDependencies { + readonly run: Run; + readonly branchPullRequest: GitManager["Service"]["branchPullRequest"]; + readonly serverSettings: ServerSettingsService["Service"]; + readonly sourceControlProviders: SourceControlProviderRegistry["Service"] | undefined; + readonly sourceControlRateLimits: + | SourceControlRateLimit.SourceControlRateLimit["Service"] + | undefined; + readonly textGeneration: TextGeneration["Service"] | undefined; + readonly getProviders: Effect.Effect>; + readonly projectIdForWorkspace?: ( + cwd: string, + ) => Effect.Effect; + readonly resolveWritingPolicy: SourceControlWritingPolicyResolver; +} + +export function makeSourceControlPanelReaders(deps: SourceControlPanelReaderDependencies) { + const { + run, + serverSettings, + sourceControlProviders, + sourceControlRateLimits, + textGeneration, + getProviders, + resolveWritingPolicy, + } = deps; + const COMMIT_PAGE_SIZE = 10; + + // A registered linked worktree owns its settings; otherwise use the main checkout's project. + const writingSettingsFor = Effect.fn("SourceControlPanelReaders.writingSettingsFor")(function* ( + cwd: string, + ) { + const settings = yield* serverSettings.getSettings; + const lookup = deps.projectIdForWorkspace; + if (!lookup || !hasProjectSettingsOverrides(settings)) return settings; + const projectId = yield* Effect.gen(function* () { + const direct = yield* lookup(cwd); + if (direct !== null) return direct; + const root = (yield* run("vcs.panel.writingWorkspaceRoot", cwd, [ + "rev-parse", + "--show-toplevel", + ])).trim(); + if (root && root !== cwd) { + const project = yield* lookup(root); + if (project !== null) return project; + } + const worktrees = yield* run("vcs.panel.writingWorktrees", cwd, [ + "worktree", + "list", + "--porcelain", + "-z", + ]); + const first = worktrees.split("\0")[0]; + const mainRoot = first?.startsWith("worktree ") ? first.slice(9) : null; + return mainRoot && mainRoot !== cwd && mainRoot !== root ? yield* lookup(mainRoot) : null; + }).pipe(Effect.orElseSucceed(() => null)); + return resolveProjectSettings(settings, projectId).settings; + }); + + const protectProviderRequest = ( + context: SourceControlProvider.SourceControlProviderContext, + effect: Effect.Effect, + ) => + sourceControlRateLimits === undefined + ? effect + : SourceControlRateLimit.protectProviderRequest({ + limits: sourceControlRateLimits, + provider: context.provider.kind, + baseUrl: context.provider.baseUrl, + effect, + }); + + const readWorkingTreeChangeGroups = ( + cwd: string, + ): Effect.Effect< + { + readonly porcelain: string; + readonly unstagedNumstat: string; + readonly prefix: string; + readonly changeGroups: VcsPanelChangeGroup[]; + }, + GitCommandError + > => + Effect.all( + [ + run("vcs.panel.statusPorcelain", cwd, [ + "-c", + "status.relativePaths=true", + "status", + "--porcelain=2", + "--branch", + "-uall", + ]), + run("vcs.panel.unstagedNumstat", cwd, [ + "diff", + "--no-relative", + "--numstat", + "-z", + "--find-renames=20%", + ]), + run("vcs.panel.stagedNumstat", cwd, [ + "diff", + "--cached", + "--no-relative", + "--numstat", + "-z", + "--find-renames=20%", + ]), + run("vcs.panel.stagedNameStatus", cwd, [ + "diff", + "--cached", + "--no-relative", + "--name-status", + "-z", + "--find-renames=20%", + ]), + run("vcs.panel.workingTreePrefix", cwd, ["rev-parse", "--show-prefix"]), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([porcelain, unstagedNumstat, stagedNumstat, stagedNameStatus, prefixOutput]) => { + const prefix = prefixOutput.replace(/\r?\n$/u, ""); + const stagedFiles = fileChangesRelativeToCwd( + parseFileChangesFromNumstat({ + numstat: stagedNumstat, + statuses: parseNameStatus(stagedNameStatus), + }), + prefix, + ); + return { + porcelain, + unstagedNumstat, + prefix, + changeGroups: parsePorcelainStatus({ + status: porcelain, + stagedFiles, + stagedStats: parseNumstat(stagedNumstat, prefix), + unstagedStats: parseNumstat(unstagedNumstat, prefix), + untrackedStats: new Map(), + }), + }; + }), + ); + + const changeGroupsHaveFiles = (groups: readonly VcsPanelChangeGroup[]) => + groups.some((group) => group.files.length > 0); + + const commitFiles = (cwd: string, sha: string) => + Effect.all( + [ + run("vcs.panel.commitNumstat", cwd, [ + "show", + "--format=", + "--numstat", + "-z", + "--find-renames", + sha, + ]), + run("vcs.panel.commitNameStatus", cwd, [ + "show", + "--format=", + "--name-status", + "-z", + "--find-renames", + sha, + ]), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([numstat, nameStatus]) => + parseFileChangesFromNumstat({ + numstat, + statuses: parseNameStatus(nameStatus), + }), + ), + ); + + const commitRefsBySha = (cwd: string, commits: VcsPanelSnapshotResult["recentCommits"]) => { + const commitShas = new Set(commits.map((commit) => commit.sha)); + if (commitShas.size === 0) { + return Effect.succeed( + new Map< + string, + { readonly headRefs: readonly string[]; readonly tags: readonly string[] } + >(), + ); + } + + return run("vcs.panel.commitRefs", cwd, [ + "for-each-ref", + "--format=%(objectname)%09%(*objectname)%09%(refname:short)%09%(refname)", + "refs/heads", + "refs/remotes", + "refs/tags", + ]).pipe( + Effect.map((output) => { + const refs = new Map< + string, + { readonly headRefs: readonly string[]; readonly tags: readonly string[] } + >(); + for (const line of output.split(/\r?\n/u)) { + const [objectName, peeledObjectName, shortRefName, fullRefName] = line.split("\t"); + const sha = peeledObjectName || objectName; + if (!sha || !shortRefName || !fullRefName || !commitShas.has(sha)) continue; + if (shortRefName.endsWith("/HEAD") || shortRefName.includes(" -> ")) continue; + if (fullRefName.startsWith("refs/remotes/") && !shortRefName.includes("/")) continue; + + const current = refs.get(sha) ?? { headRefs: [], tags: [] }; + if (fullRefName.startsWith("refs/tags/")) { + refs.set(sha, { + headRefs: current.headRefs, + tags: [...current.tags, shortRefName].toSorted((left, right) => + left.localeCompare(right), + ), + }); + continue; + } + refs.set(sha, { + headRefs: [...current.headRefs, shortRefName].toSorted((left, right) => + left.localeCompare(right), + ), + tags: current.tags, + }); + } + return refs; + }), + Effect.orElseSucceed( + () => + new Map< + string, + { readonly headRefs: readonly string[]; readonly tags: readonly string[] } + >(), + ), + ); + }; + + const commitAvatarProviderContexts = (cwd: string) => { + if (!sourceControlProviders) { + return Effect.succeed>([]); + } + + return Effect.all({ + settings: serverSettings.getSettings, + remotes: run("vcs.panel.commitAvatarRemotes", cwd, ["remote", "-v"]), + }).pipe( + Effect.map(({ settings, remotes }) => + parseRemoteVerbose(remotes) + .map(providerContextForRemote) + .filter( + (context): context is SourceControlProvider.SourceControlProviderContext => + context !== null, + ) + .filter( + (context) => + isConfiguredSourceControlProviderKind(context.provider.kind) && + context.provider.kind !== "forgejo" && + settings.sourceControl.providers[context.provider.kind]?.showCommitAuthorAvatar === + true, + ), + ), + Effect.orElseSucceed(() => []), + ); + }; + + const providerAvatarUrlForCommit = ( + cwd: string, + registry: SourceControlProviderRegistry["Service"], + contexts: ReadonlyArray, + commit: VcsPanelSnapshotResult["recentCommits"][number], + ) => + Effect.gen(function* () { + for (const context of contexts) { + const provider = yield* registry + .get(context.provider.kind) + .pipe(Effect.orElseSucceed(() => null)); + if (!provider) continue; + const avatarUrl = yield* protectProviderRequest( + context, + provider.getCommitAvatarUrl({ + cwd, + context, + sha: commit.sha, + authorEmail: commit.authorEmail, + }), + ).pipe(Effect.orElseSucceed(() => null)); + if (avatarUrl) return avatarUrl; + } + return null; + }); + + const withCommitAvatars = (cwd: string, commits: VcsPanelSnapshotResult["recentCommits"]) => { + if (commits.length === 0 || !sourceControlProviders) { + return Effect.succeed(commits); + } + + const registry = sourceControlProviders; + return commitAvatarProviderContexts(cwd).pipe( + Effect.flatMap((contexts) => { + if (contexts.length === 0) { + return Effect.succeed(commits); + } + + // Required and intended behavior: commit rows should show the source-control + // provider account avatar image when the provider exposes one. `null` is only + // the initials fallback path; do not replace this with generated avatars. + return Effect.forEach( + commits, + (commit) => + providerAvatarUrlForCommit(cwd, registry, contexts, commit).pipe( + Effect.map((authorAvatarUrl) => + authorAvatarUrl ? { ...commit, authorAvatarUrl } : commit, + ), + ), + { concurrency: 4 }, + ); + }), + Effect.orElseSucceed(() => commits), + ); + }; + + const withCommitDetails = ( + cwd: string, + commits: VcsPanelSnapshotResult["recentCommits"], + deferFiles = false, + ) => + withCommitAvatars(cwd, commits).pipe( + Effect.flatMap((withAvatars) => + commitRefsBySha(cwd, withAvatars).pipe( + Effect.flatMap((refs) => + Effect.forEach( + withAvatars, + (commit) => { + const summary = { + ...commit, + ...(refs.get(commit.sha) ?? { headRefs: [], tags: [] }), + }; + // Older clients expect files inline and do not know the detail endpoint. + return deferFiles + ? Effect.succeed({ ...summary, files: [], filesDeferred: true }) + : commitFiles(cwd, commit.sha).pipe( + Effect.orElseSucceed(() => []), + Effect.map((files) => ({ ...summary, files })), + ); + }, + { concurrency: 2 }, + ), + ), + ), + ), + ); + + const parseCount = (value: string) => { + const parsed = Number.parseInt(value.trim(), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + }; + + const countCommitsForRange = (cwd: string, range: string) => + run("vcs.panel.branchCommitCount", cwd, ["rev-list", "--count", range]).pipe( + Effect.map(parseCount), + Effect.orElseSucceed(() => 0), + ); + + const countAheadBehindForRefs = (cwd: string, leftRef: string, rightRef: string) => + run("vcs.panel.branchForkAheadBehind", cwd, [ + "rev-list", + "--left-right", + "--count", + `${leftRef}...${rightRef}`, + ]).pipe( + Effect.map(parseAheadBehindCounts), + Effect.orElseSucceed(() => ({ aheadCount: 0, behindCount: 0 })), + ); + + const refsShareAncestry = (cwd: string, leftRef: string, rightRef: string) => + run("vcs.panel.branchForkMergeBase", cwd, ["merge-base", leftRef, rightRef], { + allowNonZeroExit: true, + }).pipe( + Effect.map((output) => output.trim().length > 0), + Effect.orElseSucceed(() => false), + ); + + const actionableForkBranches = ( + cwd: string, + localBranches: readonly VcsRef[], + remotes: readonly VcsPanelRemote[], + ): Effect.Effect => { + const uniqueForks = ( + forks: readonly VcsPanelActionableForkBranch[], + ): readonly VcsPanelActionableForkBranch[] => { + const byKey = new Map(); + for (const fork of forks) { + const key = `${fork.localBranchName}\0${fork.remoteRefName}`; + const existing = byKey.get(key); + if (!existing || fork.pr || fork.behindCount > existing.behindCount) { + byKey.set(key, fork); + } + } + return [...byKey.values()].toSorted((left, right) => { + const activity = branchActivityTime(right) - branchActivityTime(left); + return activity !== 0 + ? activity + : `${left.remoteName}/${left.remoteBranchName}`.localeCompare( + `${right.remoteName}/${right.remoteBranchName}`, + ); + }); + }; + + const candidates = + remotes.length < 2 + ? [] + : localBranches.flatMap((localBranch) => + remotes.flatMap((remote) => + remote.branches + .filter((remoteBranch) => remoteBranch.name === localBranch.name) + .filter( + (remoteBranch) => + localBranch.upstreamRemoteName !== remote.name || + localBranch.upstreamName !== remoteBranch.fullRefName, + ) + .map((remoteBranch) => ({ localBranch, remote, remoteBranch })), + ), + ); + const sameNameForks = Effect.forEach( + candidates, + ({ localBranch, remote, remoteBranch }) => + Effect.gen(function* () { + const shareAncestry = yield* refsShareAncestry( + cwd, + localBranch.name, + remoteBranch.fullRefName, + ); + if (!shareAncestry) return null; + const counts = yield* countAheadBehindForRefs( + cwd, + localBranch.name, + remoteBranch.fullRefName, + ); + if (counts.behindCount <= 0) return null; + const fork = { + localBranchName: localBranch.name, + remoteName: remote.name, + remoteBranchName: remoteBranch.name, + remoteRefName: remoteBranch.fullRefName, + aheadCount: counts.aheadCount, + behindCount: counts.behindCount, + }; + return { + ...fork, + ...(remoteBranch.lastActivityAt ? { lastActivityAt: remoteBranch.lastActivityAt } : {}), + } satisfies VcsPanelActionableForkBranch; + }), + { concurrency: 4 }, + ).pipe(Effect.map((forks) => forks.flatMap((fork) => (fork ? [fork] : [])))); + + const pullRequestForks = Effect.forEach( + localBranches, + (localBranch) => + deps + .branchPullRequest({ cwd: localBranch.worktreePath ?? cwd, branch: localBranch.name }) + .pipe( + Effect.flatMap((pullRequest) => { + if (pullRequest?.state !== "open" || !pullRequest.repositoryKey) { + return Effect.succeed(null); + } + const repositoryKey = canonicalRepositoryKey(pullRequest.repositoryKey); + const remote = remotes.find( + (candidate) => + candidate.fetchUrl !== null && + candidate.branches.some((branch) => branch.name === pullRequest.baseRef) && + canonicalRepositoryKey(normalizeGitRemoteUrl(candidate.fetchUrl)) === + repositoryKey, + ); + return remote + ? actionableForkForChangeRequest(cwd, localBranch, remote, pullRequest) + : Effect.succeed(null); + }), + Effect.orElseSucceed(() => null), + ), + { concurrency: 4 }, + ).pipe(Effect.map((forks) => forks.flatMap((fork) => (fork ? [fork] : [])))); + + return Effect.all([sameNameForks, pullRequestForks], { concurrency: "unbounded" }).pipe( + Effect.map(([forks, prForks]) => uniqueForks([...forks, ...prForks])), + Effect.orElseSucceed(() => []), + ); + }; + + const actionableForkForChangeRequest = ( + cwd: string, + localBranch: VcsRef, + remote: VcsPanelRemote, + changeRequest: GitBranchPullRequest, + ): Effect.Effect => { + const remoteBranch = remote.branches.find((branch) => branch.name === changeRequest.baseRef); + if (!remoteBranch) return Effect.succeed(null); + + return Effect.gen(function* () { + const shareAncestry = yield* refsShareAncestry( + cwd, + localBranch.name, + remoteBranch.fullRefName, + ); + if (!shareAncestry) return null; + const counts = yield* countAheadBehindForRefs( + cwd, + localBranch.name, + remoteBranch.fullRefName, + ); + if (counts.behindCount <= 0) return null; + const fork = { + localBranchName: localBranch.name, + remoteName: remote.name, + remoteBranchName: remoteBranch.name, + remoteRefName: remoteBranch.fullRefName, + aheadCount: counts.aheadCount, + behindCount: counts.behindCount, + }; + return { + ...fork, + pr: changeRequest, + ...(remoteBranch.lastActivityAt ? { lastActivityAt: remoteBranch.lastActivityAt } : {}), + } satisfies VcsPanelActionableForkBranch; + }).pipe(Effect.orElseSucceed(() => null)); + }; + + const commitShasForRange = (cwd: string, range: string) => + run("vcs.panel.branchCommitShas", cwd, ["rev-list", range]).pipe( + Effect.map((output) => + output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean), + ), + Effect.orElseSucceed(() => []), + ); + + const commitsForRange = ( + cwd: string, + range: string, + maxCount: number, + skip = 0, + deferFiles = false, + ): Effect.Effect => + run( + "vcs.panel.branchCommits", + cwd, + [ + "log", + ...(deferFiles ? ["--shortstat"] : []), + `--skip=${skip}`, + `--max-count=${maxCount}`, + "--format=%H%x09%h%x09%an%x09%ae%x09%aI%x09%s", + range, + ], + { env: { LC_ALL: "C", LANG: "C" } }, + ).pipe( + Effect.map(deferFiles ? parseCommitsWithStats : parseCommits), + Effect.flatMap((commits) => withCommitDetails(cwd, commits, deferFiles)), + ); + + const branchCommits = ( + cwd: string, + branch: VcsRef, + baseRef: string | null | undefined, + kind: VcsPanelBranchCommitsInput["kind"], + skip: number, + limit: number, + deferFiles = false, + ): Effect.Effect => + Effect.gen(function* () { + const refName = branch.name; + const historyRef = yield* branchCommitRange(baseRef ?? null, refName, kind ?? "history"); + if (!historyRef) { + return { + commits: [], + remaining: 0, + }; + } + const [total, commits] = yield* Effect.all( + [ + countCommitsForRange(cwd, historyRef), + commitsForRange(cwd, historyRef, limit, skip, deferFiles), + ], + { concurrency: "unbounded" }, + ); + return { + commits, + remaining: Math.max(0, total - skip - commits.length), + }; + }); + + const stashDetails = ( + cwd: string, + stashRef: string, + ): Effect.Effect => + Effect.all( + [ + run("vcs.panel.stashNumstat", cwd, [ + "stash", + "show", + "--numstat", + "-z", + "--find-renames", + "--include-untracked", + stashRef, + ]), + run("vcs.panel.stashNameStatus", cwd, [ + "stash", + "show", + "--name-status", + "-z", + "--find-renames", + "--include-untracked", + stashRef, + ]), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([numstat, nameStatus]) => + parseFileChangesFromNumstat({ + numstat, + statuses: parseNameStatus(nameStatus), + }), + ), + Effect.orElseSucceed(() => []), + Effect.map((files) => ({ + refName: stashRef, + files, + })), + ); + + const generatedStashMessage = ( + cwd: string, + mode: "all" | "staged" | "unstaged", + paths?: readonly string[], + ): Effect.Effect => + Effect.gen(function* () { + const fallback = `T3 Code ${mode} stash`; + const diffArgs = + mode === "staged" + ? (["diff", "--cached", "--stat"] as const) + : mode === "all" + ? (["diff", "HEAD", "--stat"] as const) + : (["diff", "--stat"] as const); + const patchArgs = + mode === "staged" + ? (["diff", "--cached", "--no-ext-diff", "--patch", "--minimal"] as const) + : mode === "all" + ? (["diff", "HEAD", "--no-ext-diff", "--patch", "--minimal"] as const) + : (["diff", "--no-ext-diff", "--patch", "--minimal"] as const); + const pathArgs = paths && paths.length > 0 ? (["--", ...paths] as const) : []; + const literalPathspecArgs = + paths && paths.length > 0 ? (["--literal-pathspecs"] as const) : []; + const [settings, providers, summary, patch, status] = yield* Effect.all( + [ + writingSettingsFor(cwd), + getProviders, + run("vcs.panel.stashMessageSummary", cwd, [ + ...literalPathspecArgs, + ...diffArgs, + ...pathArgs, + ]), + run("vcs.panel.stashMessagePatch", cwd, [ + ...literalPathspecArgs, + ...patchArgs, + ...pathArgs, + ]), + run("vcs.panel.stashMessageStatus", cwd, [ + ...literalPathspecArgs, + "status", + "--short", + ...pathArgs, + ]), + ], + { concurrency: "unbounded" }, + ); + const stagedSummary = [summary.trim(), status.trim()].filter(Boolean).join("\n"); + if (!textGeneration) return fallback; + if (stagedSummary.length === 0 && patch.trim().length === 0) return fallback; + const modelSelection = SharedServerSettings.resolveSourceControlWriterModelSelection( + settings, + providers, + ); + const policy = yield* resolveWritingPolicy(cwd, { + modelSelection, + providers, + style: settings.sourceControlWritingStyle, + }); + const generated = yield* textGeneration.generateCommitMessage({ + cwd, + branch: null, + stagedSummary: stagedSummary.slice(0, 8_000), + stagedPatch: patch.slice(0, 50_000), + modelSelection, + policy, + }); + return generated.subject.trim() || fallback; + }).pipe(Effect.orElseSucceed(() => `T3 Code ${mode} stash`)); + + const generatedCommitMessage = ( + cwd: string, + paths?: readonly string[], + env?: NodeJS.ProcessEnv, + ): Effect.Effect => + Effect.gen(function* () { + const fallback = "T3 Code changes"; + const pathArgs = paths && paths.length > 0 ? (["--", ...paths] as const) : []; + const literalPathspecArgs = pathArgs.length > 0 ? (["--literal-pathspecs"] as const) : []; + const commandOptions = env ? { env } : undefined; + const [settings, providers, summary, patch] = yield* Effect.all( + [ + writingSettingsFor(cwd), + getProviders, + run( + "vcs.panel.commitMessageSummary", + cwd, + [...literalPathspecArgs, "diff", "--cached", "--stat", ...pathArgs], + commandOptions, + ), + run( + "vcs.panel.commitMessagePatch", + cwd, + [ + ...literalPathspecArgs, + "diff", + "--cached", + "--no-ext-diff", + "--patch", + "--minimal", + ...pathArgs, + ], + commandOptions, + ), + ], + { concurrency: "unbounded" }, + ); + if (!textGeneration) return fallback; + if (summary.trim().length === 0 && patch.trim().length === 0) return fallback; + const modelSelection = SharedServerSettings.resolveSourceControlWriterModelSelection( + settings, + providers, + ); + const policy = yield* resolveWritingPolicy(cwd, { + modelSelection, + providers, + style: settings.sourceControlWritingStyle, + }); + const generated = yield* textGeneration.generateCommitMessage({ + cwd, + branch: null, + stagedSummary: summary.slice(0, 8_000), + stagedPatch: patch.slice(0, 50_000), + modelSelection, + policy, + }); + return generated.subject.trim() || fallback; + }).pipe(Effect.orElseSucceed(() => "T3 Code changes")); + + const compareFiles = (cwd: string, baseRef: string | null, refName: string) => { + if (!baseRef) return Effect.succeed([]); + return Effect.all( + [ + run("vcs.panel.branchCompareNumstat", cwd, [ + "diff", + "--numstat", + "-z", + "--find-renames", + `${baseRef}...${refName}`, + ]), + run("vcs.panel.branchCompareNameStatus", cwd, [ + "diff", + "--name-status", + "-z", + "--find-renames", + `${baseRef}...${refName}`, + ]), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([numstat, nameStatus]) => + parseFileChangesFromNumstat({ + numstat, + statuses: parseNameStatus(nameStatus), + }), + ), + Effect.orElseSucceed(() => []), + ); + }; + + const branchCommitRange = ( + baseRef: string | null, + refName: string, + kind: NonNullable, + ) => { + switch (kind) { + case "ahead": + return Effect.succeed(baseRef ? `${baseRef}..${refName}` : ""); + case "behind": + return Effect.succeed(baseRef ? `${refName}..${baseRef}` : ""); + case "compare-history": + return Effect.succeed(baseRef ? `${baseRef}...${refName}` : refName); + case "history": + return Effect.succeed(refName); + } + }; + + const upstreamForRef = (cwd: string, refName: string) => + run("vcs.panel.branchUpstream", cwd, [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + `${refName}@{upstream}`, + ]).pipe( + Effect.map((value) => value.trim()), + Effect.orElseSucceed(() => ""), + Effect.map((value) => (value.length > 0 ? value : null)), + ); + + const refExists = (operation: string, cwd: string, refName: string) => + run(operation, cwd, ["show-ref", "--verify", refName], { allowNonZeroExit: true }).pipe( + Effect.map((output) => output.trim().length > 0), + Effect.orElseSucceed(() => false), + ); + + const createdFromRef = (cwd: string, refName: string) => + run("vcs.panel.branchCreatedFrom", cwd, [ + "reflog", + "show", + "--format=%gs", + "--max-count=20", + refName, + ]).pipe( + Effect.map(parseCreatedFromRef), + Effect.orElseSucceed(() => null), + ); + + const branchDetails = ( + cwd: string, + branch: VcsRef, + defaultCompareRef: string | null, + compareBaseRef?: string, + deferFiles = false, + ): Effect.Effect => + Effect.gen(function* () { + const refName = branch.name; + const upstreamRef = branch.isRemote ? null : yield* upstreamForRef(cwd, refName); + const createdBaseRef = upstreamRef ? null : yield* createdFromRef(cwd, refName); + const baseRef = compareBaseRef ?? upstreamRef ?? createdBaseRef ?? defaultCompareRef; + const unsyncedBaseRef = branch.isRemote ? null : (upstreamRef ?? defaultCompareRef); + const historyRef = refName; + const [ + aheadCommits, + aheadCommitTotal, + behindCommits, + behindCommitTotal, + totalCommits, + commits, + files, + unsyncedCommitShas, + ] = yield* Effect.all( + [ + baseRef + ? commitsForRange(cwd, `${baseRef}..${refName}`, COMMIT_PAGE_SIZE, 0, deferFiles) + : Effect.succeed([]), + baseRef ? countCommitsForRange(cwd, `${baseRef}..${refName}`) : Effect.succeed(0), + baseRef + ? commitsForRange(cwd, `${refName}..${baseRef}`, COMMIT_PAGE_SIZE, 0, deferFiles) + : Effect.succeed([]), + baseRef ? countCommitsForRange(cwd, `${refName}..${baseRef}`) : Effect.succeed(0), + countCommitsForRange(cwd, historyRef), + commitsForRange(cwd, historyRef, COMMIT_PAGE_SIZE, 0, deferFiles), + compareFiles(cwd, baseRef, refName), + unsyncedBaseRef + ? commitShasForRange(cwd, `${unsyncedBaseRef}..${refName}`) + : Effect.succeed([]), + ], + { concurrency: "unbounded" }, + ); + return { + name: branch.name, + fullRefName: branch.name, + isRemote: branch.isRemote === true, + remoteName: branch.remoteName ?? null, + current: branch.current, + isDefault: branch.isDefault, + worktreePath: branch.worktreePath, + upstreamRef, + baseRef, + unsyncedCommitShas, + aheadCommits, + aheadCommitsRemaining: Math.max(0, aheadCommitTotal - aheadCommits.length), + behindCommits, + behindCommitsRemaining: Math.max(0, behindCommitTotal - behindCommits.length), + compareCommits: [], + compareCommitsRemaining: 0, + commits, + commitsRemaining: Math.max(0, totalCommits - commits.length), + compareFiles: files, + }; + }); + + return { + actionableForkBranches, + actionableForkForChangeRequest, + commitFiles, + branchCommits, + branchDetails, + changeGroupsHaveFiles, + generatedCommitMessage, + generatedStashMessage, + readWorkingTreeChangeGroups, + refExists, + stashDetails, + upstreamForRef, + withCommitDetails, + }; +} diff --git a/apps/server/src/sourceControl/SourceControlPanelService.Branches.test.ts b/apps/server/src/sourceControl/SourceControlPanelService.Branches.test.ts new file mode 100644 index 000000000000..9a5bcead44c7 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.Branches.test.ts @@ -0,0 +1,1113 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + GitCommandError, + SourceControlProviderError, + type ChangeRequest, + type SourceControlProviderKind, + type VcsRef, + type VcsStatusLocalResult, +} from "@t3tools/contracts"; + +import { + SourceControlPanelService, + layer as SourceControlPanelServiceLayer, +} from "./SourceControlPanelService.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { GitManager } from "../git/GitManager.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { GitVcsDriver, type ExecuteGitInput, type ExecuteGitResult } from "../vcs/GitVcsDriver.ts"; + +const branchRef: VcsRef = { + name: "feature/source-control", + current: false, + isDefault: false, + worktreePath: null, +}; +const isGitCommandError = Schema.is(GitCommandError); + +const success = (stdout = ""): ExecuteGitResult => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const failure = (stderr: string): ExecuteGitResult => ({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr, + stdoutTruncated: false, + stderrTruncated: false, +}); + +const emptyProvider = SourceControlProvider.SourceControlProvider.of({ + kind: "unknown", + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getChangeRequest", + cwd: "/repo", + detail: "get change request not stubbed", + }), + ), + createChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createChangeRequest", + cwd: "/repo", + detail: "create change request not stubbed", + }), + ), + getRepositoryCloneUrls: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getRepositoryCloneUrls", + cwd: "/repo", + detail: "repository clone URLs not stubbed", + }), + ), + getCommitAvatarUrl: () => Effect.succeed(null), + createRepository: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createRepository", + cwd: "/repo", + detail: "create repository not stubbed", + }), + ), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.checkoutChangeRequest", + cwd: "/repo", + detail: "checkout change request not stubbed", + }), + ), +}); + +function makeTestLayer( + execute: (input: ExecuteGitInput) => Effect.Effect, + workflow: Partial = {}, + providers: Partial< + Record + > = {}, + settings: Parameters[0] = {}, +) { + return SourceControlPanelServiceLayer.pipe( + Layer.provide(Layer.mock(GitManager)({ branchPullRequest: () => Effect.succeed(null) })), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest(settings)), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + }), + ), + Layer.provide( + Layer.succeed(GitWorkflowService, { + status: (input) => + workflow.status + ? workflow.status(input) + : workflow.localStatus + ? workflow.localStatus(input).pipe( + Effect.map((status) => ({ + ...status, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: + (status as { readonly aheadOfDefaultCount?: number }).aheadOfDefaultCount ?? + 0, + pr: null, + })), + ) + : Effect.fail( + new GitCommandError({ + operation: "test.status", + command: "git status", + cwd: "/repo", + detail: "status not stubbed", + }), + ), + localStatus: () => + Effect.fail( + new GitCommandError({ + operation: "test.localStatus", + command: "git status", + cwd: "/repo", + detail: "local status not stubbed", + }), + ), + pullCurrentBranch: () => + Effect.fail( + new GitCommandError({ + operation: "test.pullCurrentBranch", + command: "git pull", + cwd: "/repo", + detail: "pull not stubbed", + }), + ), + ...workflow, + } as GitWorkflowService["Service"]), + ), + Layer.provide( + Layer.succeed(GitVcsDriver, { + execute, + invalidateRefs: () => Effect.void, + } as unknown as GitVcsDriver["Service"]), + ), + Layer.provide( + Layer.succeed( + SourceControlProviderRegistry, + SourceControlProviderRegistry.of({ + resolveLink: () => Effect.die("unused resolveLink"), + get: (kind) => Effect.succeed(providers[kind] ?? emptyProvider), + resolveHandle: () => Effect.succeed({ provider: emptyProvider, context: null }), + resolve: () => Effect.succeed(emptyProvider), + discover: Effect.succeed([]), + }), + ), + ), + ); +} + +/** Fails like the real driver does: stderr reaches progress callbacks, never the error detail. */ +const gitFailure = (input: ExecuteGitInput, stderrLines: readonly string[]) => + Effect.gen(function* () { + for (const line of stderrLines) { + yield* input.progress?.onStderrLine?.(line) ?? Effect.void; + } + return yield* new GitCommandError({ + operation: input.operation, + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "Git command exited with a non-zero status.", + exitCode: 1, + stderrLength: stderrLines.join("\n").length, + }); + }); + +const localBranchGitOutput = (input: ExecuteGitInput): ExecuteGitResult => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + "feature/source-control\t\t\t2026-06-20T12:00:00.000Z\torigin/feature/source-control\t", + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head main"); + default: + return success(""); + } +}; + +// The branch is registered to /repo-pr-66, whose directory no longer exists. +const staleWorktreeGitOutput = (input: ExecuteGitInput): ExecuteGitResult => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + "feature/source-control\t\t/repo-pr-66\t2026-06-20T12:00:00.000Z\torigin/feature/source-control\t", + ); + case "vcs.panel.worktrees": + case "vcs.panel.deleteBranch.worktrees": + return success( + [ + "worktree /repo", + "HEAD abc", + "branch refs/heads/main", + "", + "worktree /repo-pr-66", + "HEAD def", + "branch refs/heads/feature/source-control", + "prunable gitdir file points to non-existent location", + "", + ].join("\n"), + ); + default: + return localBranchGitOutput(input); + } +}; + +const localStatus: VcsStatusLocalResult = { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/source-control", + hasWorkingTreeChanges: true, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, +}; + +describe("SourceControlPanelService", () => { + it.effect("pulls non-current branches from upstream remotes with slashes in their name", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.pullBranch({ + cwd: "/repo", + branchName: "main", + }); + + const fetchCall = calls.find((call) => call.operation === "vcs.panel.pullBranch.nonCurrent"); + assert.deepStrictEqual(fetchCall?.args, [ + "fetch", + "team/upstream", + "refs/heads/main:refs/heads/main", + ]); + assert.strictEqual(fetchCall?.timeoutMs, 300_000); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.branchUpstream": + return success("team/upstream/main\n"); + case "vcs.panel.pullBranch.remotes": + return success("origin\nteam/upstream\n"); + default: + return success(""); + } + }), + { + status: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }, + ), + ), + ); + }); + + it.effect("rejects slashful upstreams that do not match a known remote", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .pullBranch({ + cwd: "/repo", + branchName: "main", + }) + .pipe(Effect.flip); + + assert.equal(error.operation, "vcs.panel.pullBranch"); + assert.equal(error.detail, "Branch main has invalid upstream team/upstream/main."); + assert.equal( + calls.some((call) => call.operation === "vcs.panel.pullBranch.nonCurrent"), + false, + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.branchUpstream": + return success("team/upstream/main\n"); + case "vcs.panel.pullBranch.remotes": + return success("origin\n"); + default: + return success(""); + } + }), + { + status: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }, + ), + ), + ); + }); + + it.effect("rejects slashless local upstreams when pulling non-current branches", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .pullBranch({ + cwd: "/repo", + branchName: "feature/source-control", + }) + .pipe(Effect.flip); + + assert.equal(error.operation, "vcs.panel.pullBranch"); + assert.equal(error.detail, "Branch feature/source-control has invalid upstream main."); + assert.equal( + calls.some((call) => call.operation === "vcs.panel.pullBranch.nonCurrent"), + false, + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.branchUpstream": + return success("main\n"); + case "vcs.panel.pullBranch.remotes": + return success("origin\nupstream\n"); + default: + return success(""); + } + }), + { + status: () => + Effect.succeed({ + ...localStatus, + refName: "main", + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }, + ), + ), + ); + }); + + it.effect("fetches branches from upstream remotes with slashes in their name", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.fetchBranch({ + cwd: "/repo", + branchName: "team/upstream/main", + }); + + const fetchCall = calls.find((call) => call.operation === "vcs.panel.fetchBranch"); + assert.deepStrictEqual(fetchCall?.args, [ + "fetch", + "team/upstream", + "refs/heads/main:refs/remotes/team/upstream/main", + ]); + assert.strictEqual(fetchCall?.timeoutMs, 300_000); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.fetchBranch.remotes": + return success("origin\nteam/upstream\n"); + case "vcs.panel.fetchBranch.remoteBranch": + return success("abc123 refs/remotes/team/upstream/main\n"); + default: + return success(""); + } + }), + ), + ), + ); + }); + + it.effect("resolves local branch deletion from the server snapshot", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.deleteBranch({ + cwd: "/repo", + branchName: "feature/source-control", + force: true, + }); + + const deleteCall = calls.find((call) => call.operation === "vcs.panel.deleteLocalBranch"); + assert.deepStrictEqual(deleteCall?.args, ["branch", "-D", "feature/source-control"]); + assert.isFalse( + calls.some((call) => call.operation === "vcs.panel.deleteBranch.pruneWorktrees"), + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + "feature/source-control\t\t\t2026-06-20T12:00:00.000Z\torigin/feature/source-control\t", + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head main"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.stagedNameStatus": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ); + }); + + it.effect("rejects deletion of a branch checked out in another worktree", () => { + const calls: ExecuteGitInput[] = []; + const siblingWorktreePath = process.cwd(); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .deleteBranch({ + cwd: "/repo", + branchName: "feature/source-control", + force: true, + }) + .pipe(Effect.flip); + + assert.equal(error.operation, "vcs.panel.deleteBranch"); + assert.equal(error.detail, "Cannot delete a branch that is checked out in another worktree."); + assert.isFalse(calls.some((call) => call.operation === "vcs.panel.deleteLocalBranch")); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + `feature/source-control\t\t${siblingWorktreePath}\t2026-06-20T12:00:00.000Z\torigin/feature/source-control\t`, + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head main"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.stagedNameStatus": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ); + }); + + it.effect("prunes a stale worktree registration before deleting its branch", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.deleteBranch({ + cwd: "/repo", + branchName: "feature/source-control", + }); + + const operations = calls.map((call) => call.operation); + const pruneIndex = operations.indexOf("vcs.panel.deleteBranch.pruneWorktrees"); + const deleteIndex = operations.indexOf("vcs.panel.deleteLocalBranch"); + assert.deepStrictEqual(calls[pruneIndex]?.args, ["worktree", "prune"]); + assert.deepStrictEqual(calls[deleteIndex]?.args, ["branch", "-d", "feature/source-control"]); + assert.isTrue(pruneIndex < deleteIndex); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + return staleWorktreeGitOutput(input); + }), + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ); + }); + + it.effect("names the registered worktree when Git still refuses the delete after pruning", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .deleteBranch({ + cwd: "/repo", + branchName: "feature/source-control", + }) + .pipe(Effect.flip); + + assert.equal(error.operation, "vcs.panel.deleteLocalBranch"); + assert.equal( + error.detail, + "Branch feature/source-control is still used by the worktree at /repo-pr-66. Remove that worktree before deleting the branch.", + ); + assert.isTrue( + calls.some((call) => call.operation === "vcs.panel.deleteBranch.pruneWorktrees"), + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => { + calls.push(input); + return input.operation === "vcs.panel.deleteLocalBranch" + ? gitFailure(input, [ + "error: cannot delete branch 'feature/source-control' used by worktree at '/repo-pr-66'", + ]) + : Effect.succeed(staleWorktreeGitOutput(input)); + }, + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ); + }); + + it.effect("reports unmerged branch deletions without forwarding Git output", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .deleteBranch({ + cwd: "/repo", + branchName: "feature/source-control", + }) + .pipe(Effect.flip); + + assert.equal(error.operation, "vcs.panel.deleteLocalBranch"); + assert.equal( + error.detail, + "Branch feature/source-control is not fully merged. Force delete it to discard its unmerged commits.", + ); + assert.isFalse( + calls.some((call) => call.operation === "vcs.panel.deleteBranch.pruneWorktrees"), + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => { + calls.push(input); + return input.operation === "vcs.panel.deleteLocalBranch" + ? gitFailure(input, [ + "error: the branch 'feature/source-control' is not fully merged", + "hint: If you are sure you want to delete it, run 'git branch -D feature/source-control'", + ]) + : Effect.succeed(localBranchGitOutput(input)); + }, + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ); + }); + + it.effect("keeps the generic detail for unrecognized delete failures", () => { + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .deleteBranch({ + cwd: "/repo", + branchName: "feature/source-control", + }) + .pipe(Effect.flip); + + assert.equal(error.detail, "Git command exited with a non-zero status."); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + input.operation === "vcs.panel.deleteLocalBranch" + ? gitFailure(input, ["fatal: unexpected failure mentioning token=secret"]) + : Effect.succeed(localBranchGitOutput(input)), + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ); + }); + + it.effect("resolves remote branch deletion from the server snapshot", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.deleteBranch({ + cwd: "/repo", + branchName: "origin/feature/source-control", + remoteName: "origin", + }); + + const deleteCall = calls.find((call) => call.operation === "vcs.panel.deleteRemoteBranch"); + assert.deepStrictEqual(deleteCall?.args, [ + "push", + "origin", + "--delete", + "feature/source-control", + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.localBranches": + return success(""); + case "vcs.panel.remotes": + return success( + [ + "origin\tgit@example.test:fork/repo.git\t(fetch)", + "origin\tgit@example.test:fork/repo.git\t(push)", + ].join("\n"), + ); + case "vcs.panel.remoteBranches": + return success("origin/feature/source-control\t2026-06-20T12:00:00.000Z\n"); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head main"); + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.stagedNameStatus": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ); + }); + + for (const scenario of [ + { label: "remote", localPresent: true, remoteName: "origin", expected: "remote" }, + { label: "local", localPresent: true, remoteName: undefined, expected: "local" }, + { label: "missing local", localPresent: false, remoteName: undefined, expected: "missing" }, + { label: "missing remote", localPresent: true, remoteName: "other", expected: "missing" }, + ] as const) { + it.effect(`preserves ${scenario.label} deletion identity when branch names collide`, () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const deletion = service.deleteBranch({ + cwd: "/repo", + branchName: "origin/main", + ...(scenario.remoteName ? { remoteName: scenario.remoteName } : {}), + }); + if (scenario.expected === "missing") { + const error = yield* deletion.pipe(Effect.flip); + assert.include(error.detail, "was not found in the current source-control snapshot"); + } else { + yield* deletion; + } + + const deletes = calls.filter((call) => + ["vcs.panel.deleteLocalBranch", "vcs.panel.deleteRemoteBranch"].includes(call.operation), + ); + assert.deepStrictEqual( + deletes.map((call) => call.args), + scenario.expected === "remote" + ? [["push", "origin", "--delete", "main"]] + : scenario.expected === "local" + ? [["branch", "-d", "origin/main"]] + : [], + ); + const localFormat = calls.find( + (call) => call.operation === "vcs.panel.localBranches", + )?.args; + assert.include(localFormat?.join(" ") ?? "", "%(refname:lstrip=2)"); + assert.include(localFormat?.join(" ") ?? "", "%(upstream:lstrip=2)"); + assert.include( + calls.find((call) => call.operation === "vcs.panel.remoteBranches")?.args.join(" ") ?? "", + "%(refname:lstrip=2)", + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + scenario.localPresent ? "origin/main\t\t\t2026-06-20T12:00:00.000Z\t\t" : "", + ); + case "vcs.panel.remotes": + return success("origin\tgit@example.test:fork/repo.git\t(fetch)"); + case "vcs.panel.remoteBranches": + return success("origin/main\t2026-06-20T12:00:00.000Z"); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head main"); + default: + return success(""); + } + }), + { localStatus: () => Effect.succeed(localStatus) }, + ), + ), + ); + }); + } + + it.effect("fetches local branches with remote-looking names from their upstream", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.fetchBranch({ + cwd: "/repo", + branchName: "origin/feature", + }); + + const fetchCall = calls.find((call) => call.operation === "vcs.panel.fetchBranch"); + assert.deepStrictEqual(fetchCall?.args, [ + "fetch", + "upstream", + "refs/heads/main:refs/remotes/upstream/main", + ]); + assert.equal( + calls.some((call) => call.operation === "vcs.panel.fetchBranch.remoteBranch"), + false, + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.fetchBranch.remotes": + return success("origin\nupstream\n"); + case "vcs.panel.fetchBranch.localBranch": + return success("abc123 refs/heads/origin/feature\n"); + case "vcs.panel.branchUpstream": + return success("upstream/main\n"); + default: + return success(""); + } + }), + ), + ), + ); + }); + + it.effect("rejects slashless local upstreams when fetching local branches", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .fetchBranch({ + cwd: "/repo", + branchName: "feature/source-control", + }) + .pipe(Effect.flip); + + assert.equal(error.operation, "vcs.panel.fetchBranch"); + assert.equal(error.detail, "Branch feature/source-control has invalid upstream main."); + assert.equal( + calls.some((call) => call.operation === "vcs.panel.fetchBranch"), + false, + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.fetchBranch.remotes": + return success("origin\nupstream\n"); + case "vcs.panel.fetchBranch.localBranch": + return success("abc123 refs/heads/feature/source-control\n"); + case "vcs.panel.branchUpstream": + return success("main\n"); + default: + return success(""); + } + }), + ), + ), + ); + }); + + it.effect("defers untracked detail loading from the initial snapshot", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + const unstagedFiles = + snapshot.changeGroups.find((group) => group.kind === "unstaged")?.files ?? []; + + assert.equal(unstagedFiles.length, 101); + assert.deepStrictEqual(unstagedFiles[0], { + path: "generated/file-000.txt", + originalPath: null, + status: "untracked", + insertions: 0, + deletions: 0, + }); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + assert.notInclude( + [ + "vcs.panel.untrackedNumstat", + "vcs.panel.gitIndexPath", + "vcs.panel.tempIndexIntentToAdd", + "vcs.panel.unstagedNameStatusWithUntracked", + "vcs.panel.unstagedNumstatWithUntracked", + ], + input.operation, + ); + + switch (input.operation) { + case "vcs.panel.localBranches": + case "vcs.panel.remotes": + case "vcs.panel.stashes": + return success(""); + case "vcs.panel.statusPorcelain": + return success( + [ + "# branch.oid abc", + "# branch.head main", + ...Array.from( + { length: 101 }, + (_, index) => `? generated/file-${index.toString().padStart(3, "0")}.txt`, + ), + ].join("\n"), + ); + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ), + ); + + it.effect("uses the repository default branch as the default compare ref even when current", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + + assert.equal(snapshot.defaultCompareRef, "main"); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + [ + "main\t*\t/repo\t2026-06-20T12:00:00.000Z\torigin/main\t", + "feature/source-control\t\t\t2026-06-19T12:00:00.000Z\t\t", + ].join("\n"), + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head main"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "main", + isDefaultRef: true, + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ), + ); + + it.effect("keeps a non-main current default branch as the default compare ref", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + + assert.equal(snapshot.defaultCompareRef, "develop"); + assert.deepStrictEqual( + snapshot.localBranches.map((ref) => ({ name: ref.name, isDefault: ref.isDefault })), + [ + { name: "develop", isDefault: true }, + { name: "feature/source-control", isDefault: false }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + [ + "develop\t*\t/repo\t2026-06-20T12:00:00.000Z\torigin/develop\t", + "feature/source-control\t\t\t2026-06-19T12:00:00.000Z\t\t", + ].join("\n"), + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head develop"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "develop", + isDefaultRef: true, + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ), + ); + + it.effect("surfaces same-name remote forks only when the local branch is behind", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + + assert.deepStrictEqual(snapshot.actionableForkBranches, [ + { + localBranchName: "feature", + remoteName: "upstream", + remoteBranchName: "feature", + remoteRefName: "upstream/feature", + aheadCount: 2, + behindCount: 3, + lastActivityAt: "2026-06-17T09:00:00.000Z", + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + "feature\t*\t/repo\t2026-06-17T10:00:00.000Z\torigin/feature\t[ahead 1]\torigin", + ); + case "vcs.panel.remotes": + return success( + [ + "origin\tgit@example.test:fork/repo.git\t(fetch)", + "origin\tgit@example.test:fork/repo.git\t(push)", + "upstream\tgit@example.test:upstream/repo.git\t(fetch)", + "upstream\tgit@example.test:upstream/repo.git\t(push)", + ].join("\n"), + ); + case "vcs.panel.remoteBranches": + return input.args.includes("origin/*") + ? success("origin/feature\t2026-06-17T08:00:00.000Z\n") + : success("upstream/feature\t2026-06-17T09:00:00.000Z\n"); + case "vcs.panel.branchForkMergeBase": + return success("abc123\n"); + case "vcs.panel.branchForkAheadBehind": + return success("2\t3\n"); + case "vcs.panel.statusPorcelain": + return success(["# branch.oid abc", "# branch.head feature"].join("\n")); + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + case "vcs.panel.stashes": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "feature", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelService.Core.test.ts b/apps/server/src/sourceControl/SourceControlPanelService.Core.test.ts new file mode 100644 index 000000000000..65948930e72d --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.Core.test.ts @@ -0,0 +1,2132 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Path from "effect/Path"; +import { assert, describe, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + GitCommandError, + OrchestrationProject, + ProjectId, + ProviderInstanceId, + SourceControlProviderError, + type ChangeRequest, + type SourceControlProviderKind, + type VcsRef, + type VcsStatusLocalResult, +} from "@t3tools/contracts"; + +import { + SourceControlPanelService, + layer as SourceControlPanelServiceLayer, +} from "./SourceControlPanelService.ts"; +import { SOURCE_CONTROL_PANEL_REF_AFFECTING_ACTION_METHODS } from "./SourceControlPanelActions.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { GitManager } from "../git/GitManager.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + TextGeneration, + type CommitMessageGenerationInput, +} from "../textGeneration/TextGeneration.ts"; +import { GIT_COMMAND_TIMEOUT_MS } from "../vcs/GitCommandTimeout.ts"; +import { GitVcsDriver, type ExecuteGitInput, type ExecuteGitResult } from "../vcs/GitVcsDriver.ts"; + +const branchRef: VcsRef = { + name: "feature/source-control", + current: false, + isDefault: false, + worktreePath: null, +}; +const isGitCommandError = Schema.is(GitCommandError); + +const success = (stdout = ""): ExecuteGitResult => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const failure = (stderr: string): ExecuteGitResult => ({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr, + stdoutTruncated: false, + stderrTruncated: false, +}); + +const emptyProvider = SourceControlProvider.SourceControlProvider.of({ + kind: "unknown", + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getChangeRequest", + cwd: "/repo", + detail: "get change request not stubbed", + }), + ), + createChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createChangeRequest", + cwd: "/repo", + detail: "create change request not stubbed", + }), + ), + getRepositoryCloneUrls: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getRepositoryCloneUrls", + cwd: "/repo", + detail: "repository clone URLs not stubbed", + }), + ), + getCommitAvatarUrl: () => Effect.succeed(null), + createRepository: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createRepository", + cwd: "/repo", + detail: "create repository not stubbed", + }), + ), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.checkoutChangeRequest", + cwd: "/repo", + detail: "checkout change request not stubbed", + }), + ), +}); + +function makeTestLayer( + execute: (input: ExecuteGitInput) => Effect.Effect, + workflow: Partial = {}, + providers: Partial< + Record + > = {}, + settings: Parameters[0] = {}, + gitDriver: Partial = {}, + platformLayer = NodeServices.layer, +) { + return SourceControlPanelServiceLayer.pipe( + Layer.provide(Layer.mock(GitManager)({ branchPullRequest: () => Effect.succeed(null) })), + Layer.provideMerge(platformLayer), + Layer.provideMerge(ServerSettingsService.layerTest(settings)), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + }), + ), + Layer.provide( + Layer.succeed(GitWorkflowService, { + status: (input) => + workflow.status + ? workflow.status(input) + : workflow.localStatus + ? workflow.localStatus(input).pipe( + Effect.map((status) => ({ + ...status, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: + (status as { readonly aheadOfDefaultCount?: number }).aheadOfDefaultCount ?? + 0, + pr: null, + })), + ) + : Effect.fail( + new GitCommandError({ + operation: "test.status", + command: "git status", + cwd: "/repo", + detail: "status not stubbed", + }), + ), + localStatus: () => + Effect.fail( + new GitCommandError({ + operation: "test.localStatus", + command: "git status", + cwd: "/repo", + detail: "local status not stubbed", + }), + ), + pullCurrentBranch: () => + Effect.fail( + new GitCommandError({ + operation: "test.pullCurrentBranch", + command: "git pull", + cwd: "/repo", + detail: "pull not stubbed", + }), + ), + ...workflow, + } as GitWorkflowService["Service"]), + ), + Layer.provide( + Layer.succeed(GitVcsDriver, { + execute, + invalidateRefs: () => Effect.void, + ...gitDriver, + } as unknown as GitVcsDriver["Service"]), + ), + Layer.provide( + Layer.succeed( + SourceControlProviderRegistry, + SourceControlProviderRegistry.of({ + resolveLink: () => Effect.die("unused resolveLink"), + get: (kind) => Effect.succeed(providers[kind] ?? emptyProvider), + resolveHandle: () => Effect.succeed({ provider: emptyProvider, context: null }), + resolve: () => Effect.succeed(emptyProvider), + discover: Effect.succeed([]), + }), + ), + ), + ); +} + +const localStatus: VcsStatusLocalResult = { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/source-control", + hasWorkingTreeChanges: true, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, +}; + +describe("SourceControlPanelService", () => { + for (const action of ["applyStash", "popStash", "dropStash"] as const) { + it.effect(`${action} rejects a renumbered stash before changing files or stash entries`, () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const error = yield* service[action]({ + cwd: "/repo", + stashRef: "stash@{1}", + expectedSha: "a".repeat(40), + }).pipe(Effect.flip); + assert.match(error.detail, /selected stash has changed/); + assert.equal( + calls.some((call) => call.args[0] === "stash"), + false, + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success( + input.args[0] === "rev-parse" && input.args[1] === "--verify" + ? "b".repeat(40) + : "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/common/.git", + ); + }), + ), + ), + ); + }); + it.effect(`${action} reports a vanished selection without mutating the repository`, () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const error = yield* service[action]({ + cwd: "/repo", + stashRef: "stash@{1}", + expectedSha: "a".repeat(40), + }).pipe(Effect.flip); + assert.match(error.detail, /selected stash has changed/); + assert.equal( + calls.some((call) => call.args[0] === "stash"), + false, + ); + assert.equal(calls.find((call) => call.args[1] === "--verify")?.allowNonZeroExit, true); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.args[1] === "--verify" + ? failure("fatal: Needed a single revision") + : success("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/common/.git"); + }), + ), + ), + ); + }); + it.effect(`${action} preserves the selected stash identity`, () => { + const calls: ExecuteGitInput[] = []; + const sha = "a".repeat(40); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + yield* service[action]({ cwd: "/repo", stashRef: "stash@{1}", expectedSha: sha }); + assert.deepStrictEqual( + calls.filter((call) => call.args[0] === "stash").map((call) => call.args), + action === "applyStash" + ? [["stash", "apply", sha]] + : action === "popStash" + ? [ + ["stash", "apply", sha], + ["stash", "drop", "stash@{1}"], + ] + : [["stash", "drop", "stash@{1}"]], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(input.args[1] === "--verify" ? sha : "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/common/.git"); + }), + ), + ), + ); + }); + } + + it.effect("keeps a replacement stash when its position changes during pop application", () => { + const calls: ExecuteGitInput[] = []; + let applied = false; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const error = yield* service + .popStash({ cwd: "/repo", stashRef: "stash@{0}", expectedSha: "a".repeat(40) }) + .pipe(Effect.flip); + assert.match(error.detail, /stash changes were applied/); + assert.match(error.detail, /did not drop the stash/); + assert.match(error.detail, /do not apply it again/); + assert.equal( + calls.some((call) => call.args[1] === "drop"), + false, + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + if (input.args[1] === "apply") applied = true; + return success( + input.args[1] === "--verify" ? (applied ? "b" : "a").repeat(40) : "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/common/.git", + ); + }), + ), + ), + ); + }); + + it.effect( + "reports applied changes without dropping a stash whose selector vanished during pop", + () => { + const calls: ExecuteGitInput[] = []; + let applied = false; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const error = yield* service + .popStash({ cwd: "/repo", stashRef: "stash@{0}", expectedSha: "a".repeat(40) }) + .pipe(Effect.flip); + assert.match(error.detail, /stash changes were applied/); + assert.match(error.detail, /did not drop the stash/); + assert.match(error.detail, /do not apply it again/); + assert.deepStrictEqual( + calls.filter((call) => call.args[0] === "stash").map((call) => call.args), + [["stash", "apply", "a".repeat(40)]], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + if (input.args[1] === "apply") applied = true; + return input.args[1] === "--verify" + ? applied + ? failure("fatal: Needed a single revision") + : success("a".repeat(40)) + : success("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/common/.git"); + }), + ), + ), + ); + }, + ); + + it.effect( + "preserves a stash verification launch failure instead of reporting a stale selection", + () => { + const launchError = new GitCommandError({ + operation: "vcs.panel.popStash", + cwd: "/repo", + command: "git rev-parse", + detail: "Git process could not be launched", + }); + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const error = yield* service + .popStash({ cwd: "/repo", expectedSha: "a".repeat(40) }) + .pipe(Effect.flip); + assert.equal(error, launchError); + assert.equal( + calls.some((call) => call.args[0] === "stash"), + false, + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => { + calls.push(input); + return input.args[1] === "--verify" + ? Effect.fail(launchError) + : Effect.succeed(success("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/common/.git")); + }), + ), + ); + }, + ); + + it.effect("preserves positional compatibility when the stash SHA is omitted or null", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + yield* service.applyStash({ cwd: "/repo", stashRef: "stash@{1}" }); + yield* service.popStash({ cwd: "/repo", expectedSha: null }); + yield* service.dropStash({ cwd: "/repo", stashRef: "stash@{2}", expectedSha: null }); + assert.deepStrictEqual( + calls.filter((call) => call.args[0] === "stash").map((call) => call.args), + [ + ["stash", "apply", "stash@{1}"], + ["stash", "pop", "stash@{0}"], + ["stash", "drop", "stash@{2}"], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success("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/common/.git"); + }), + ), + ), + ); + }); + + it.effect("coordinates stash creation with another caller in a linked worktree", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const secondCanonicalized = yield* Deferred.make(); + const mutations: string[] = []; + let canonicalizations = 0; + yield* Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const first = yield* service + .applyStash({ cwd: "/root", stashRef: "stash@{0}" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(entered); + const second = yield* service + .createStash({ cwd: "/linked", message: "Save linked changes" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(secondCanonicalized); + assert.deepStrictEqual(mutations, ["apply started"]); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + assert.deepStrictEqual(mutations, ["apply started", "apply completed", "push"]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.gen(function* () { + if (input.args[0] === "rev-parse") return success("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/missing-common/.git"); + if (input.args[1] === "apply") { + mutations.push("apply started"); + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + mutations.push("apply completed"); + } else mutations.push(input.args[1]!); + return success(); + }), + undefined, + undefined, + undefined, + undefined, + Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const base = yield* FileSystem.FileSystem; + return FileSystem.FileSystem.of({ + ...base, + realPath: (path) => + Effect.gen(function* () { + if (++canonicalizations === 2) + yield* Deferred.succeed(secondCanonicalized, undefined); + return path; + }), + }); + }), + ).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), + ); + }), + ); + + it.effect("releases stash coordination when a caller is interrupted", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const created = yield* Deferred.make(); + yield* Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const first = yield* service + .applyStash({ cwd: "/root" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(entered); + const second = yield* service + .createStash({ cwd: "/linked", message: "Save linked changes" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.interrupt(first); + yield* Deferred.await(created); + yield* Fiber.join(second); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.gen(function* () { + if (input.args[0] === "rev-parse") return success("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/missing-common/.git"); + if (input.args[1] === "apply") { + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; + } + yield* Deferred.succeed(created, undefined); + return success(); + }), + ), + ), + ); + }), + ); + for (const registeredSibling of [true, false]) { + it.effect( + `resolves Git-form project paths for ${registeredSibling ? "registered" : "inherited"} sibling messages`, + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const requests: CommitMessageGenerationInput[] = []; + const projectId = ProjectId.make("windows-project"); + const project = OrchestrationProject.make({ + id: projectId, + title: "Windows project", + workspaceRoot: path.resolve(registeredSibling ? "C:/sibling" : "C:/repo"), + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + deletedAt: null, + }); + const writer = { + instanceId: ProviderInstanceId.make("codex"), + model: "project-writer", + options: [], + }; + yield* Effect.gen(function* () { + const service = yield* SourceControlPanelService; + yield* service.commitStaged({ cwd: "C:/sibling" }); + yield* service.createStash({ cwd: "C:/sibling", mode: "all" }); + assert.equal(requests.length, 2); + for (const request of requests) { + assert.deepStrictEqual(request.modelSelection, writer); + assert.equal(request.policy?.commitInstructions, "Project instructions"); + } + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.succeed( + success( + input.operation === "vcs.panel.writingWorkspaceRoot" + ? "C:/sibling" + : input.operation === "vcs.panel.writingWorktrees" + ? "worktree C:/repo\0detached\0\0" + : "changed file", + ), + ), + {}, + {}, + { + projectSettingsOverrides: { + [projectId]: { + sourceControlWriterModelSelection: null, + textGenerationModelSelection: writer, + sourceControlWritingStyle: { + mode: "custom", + customInstructions: "Project instructions", + followChangeRequestTemplates: true, + }, + }, + }, + }, + ).pipe( + Layer.provide( + Layer.mock(ProjectionSnapshotQuery)({ + getActiveProjectByWorkspaceRoot: (cwd) => + Effect.succeed( + cwd === project.workspaceRoot ? Option.some(project) : Option.none(), + ), + }), + ), + Layer.provide( + Layer.mock(TextGeneration)({ + generateCommitMessage: (request) => + Effect.sync(() => { + requests.push(request); + return { subject: "Generated message", body: "" }; + }), + }), + ), + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + } + + it.effect("keeps the default branch as its own stable comparison base", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const details = yield* service.branchDetails({ + cwd: "/repo", + branch: { + name: "develop", + current: true, + isDefault: true, + worktreePath: "/repo", + }, + defaultCompareRef: "develop", + }); + + assert.equal(details.baseRef, "develop"); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => success(input.args[0] === "rev-list" ? "0" : "")), + ), + ), + ), + ); + + it.effect("uses the selected branch head for history queries", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "history", + skip: 0, + limit: 10, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["rev-list", "--count", "feature/source-control"], + [ + "log", + "--skip=0", + "--max-count=10", + "--format=%H%x09%h%x09%an%x09%ae%x09%aI%x09%s", + "feature/source-control", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(input.args[0] === "rev-list" ? "0" : ""); + }), + ), + ), + ); + }); + + it.effect("uses the compare range for compare-history branch queries", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "compare-history", + skip: 0, + limit: 10, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["rev-list", "--count", "main...feature/source-control"], + [ + "log", + "--skip=0", + "--max-count=10", + "--format=%H%x09%h%x09%an%x09%ae%x09%aI%x09%s", + "main...feature/source-control", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(input.args[0] === "rev-list" ? "0" : ""); + }), + ), + ), + ); + }); + + it.effect("uses the selected branch for compare-history queries without a base", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: null, + kind: "compare-history", + skip: 0, + limit: 10, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["rev-list", "--count", "feature/source-control"], + [ + "log", + "--skip=0", + "--max-count=10", + "--format=%H%x09%h%x09%an%x09%ae%x09%aI%x09%s", + "feature/source-control", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(input.args[0] === "rev-list" ? "0" : ""); + }), + ), + ), + ); + }); + + it.effect("does not fetch provider account avatar URLs by default", () => { + let avatarLookupCount = 0; + + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "history", + skip: 0, + limit: 10, + }); + + assert.equal(result.commits[0]?.authorAvatarUrl, null); + assert.equal(result.commits[1]?.authorAvatarUrl, null); + assert.equal(avatarLookupCount, 0); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.args[0]) { + case "rev-list": + return success("2"); + case "log": + return success( + [ + "a".repeat(40), + "aaaaaaa", + "Ada Lovelace", + "ada@example.test", + "2026-06-27T10:00:00+00:00", + "Add source control avatars", + ].join("\t") + + "\n" + + [ + "b".repeat(40), + "bbbbbbb", + "Grace Hopper", + "grace@example.test", + "2026-06-27T09:00:00+00:00", + "Keep avatars distinct", + ].join("\t"), + ); + case "remote": + return success( + [ + "origin\thttps://github.com/pingdotgg/t3code.git (fetch)", + "origin\thttps://github.com/pingdotgg/t3code.git (push)", + ].join("\n"), + ); + default: + return success(""); + } + }), + {}, + { + github: SourceControlProvider.SourceControlProvider.of({ + ...emptyProvider, + kind: "github", + getCommitAvatarUrl: () => + Effect.sync(() => { + avatarLookupCount += 1; + return "https://avatars.githubusercontent.com/u/101?v=4"; + }), + }), + }, + ), + ), + ); + }); + + it.effect("uses opted-in provider account avatar URLs for commit authors", () => { + const avatarLookups: Array<{ + readonly sha: string; + readonly authorEmail: string | null | undefined; + readonly remoteUrl: string | undefined; + }> = []; + + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "history", + skip: 0, + limit: 10, + }); + + const firstAvatar = result.commits[0]?.authorAvatarUrl; + const secondAvatar = result.commits[1]?.authorAvatarUrl; + + if (typeof firstAvatar !== "string" || typeof secondAvatar !== "string") { + assert.fail("expected commit authors to have provider avatar URLs"); + } + assert.equal(firstAvatar, "https://avatars.githubusercontent.com/u/101?v=4"); + assert.equal(secondAvatar, "https://avatars.githubusercontent.com/u/202?v=4"); + assert.notStrictEqual(firstAvatar, secondAvatar); + assert.deepStrictEqual(avatarLookups, [ + { + sha: "a".repeat(40), + authorEmail: "ada@example.test", + remoteUrl: "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/pingdotgg/t3code.git", + }, + { + sha: "b".repeat(40), + authorEmail: "grace@example.test", + remoteUrl: "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/pingdotgg/t3code.git", + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.args[0]) { + case "rev-list": + return success("2"); + case "log": + return success( + [ + "a".repeat(40), + "aaaaaaa", + "Ada Lovelace", + "ada@example.test", + "2026-06-27T10:00:00+00:00", + "Add source control avatars", + ].join("\t") + + "\n" + + [ + "b".repeat(40), + "bbbbbbb", + "Grace Hopper", + "grace@example.test", + "2026-06-27T09:00:00+00:00", + "Keep avatars distinct", + ].join("\t"), + ); + case "remote": + return success( + [ + "origin\thttps://github.com/pingdotgg/t3code.git (fetch)", + "origin\thttps://github.com/pingdotgg/t3code.git (push)", + ].join("\n"), + ); + default: + return success(""); + } + }), + {}, + { + github: SourceControlProvider.SourceControlProvider.of({ + ...emptyProvider, + kind: "github", + getCommitAvatarUrl: (input) => + Effect.sync(() => { + avatarLookups.push({ + sha: input.sha, + authorEmail: input.authorEmail, + remoteUrl: input.context?.remoteUrl, + }); + return input.sha.startsWith("a") + ? "https://avatars.githubusercontent.com/u/101?v=4" + : "https://avatars.githubusercontent.com/u/202?v=4"; + }), + }), + }, + { + sourceControl: { + providers: { + github: { + showCommitAuthorAvatar: true, + }, + }, + }, + }, + ), + ), + ); + }); + + it.effect("keeps wrapper messages structural while preserving sanitized causes", () => { + const cause = new Error("transport closed"); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "history", + skip: 0, + limit: 10, + }) + .pipe(Effect.flip); + + assert.strictEqual(isGitCommandError(error), true); + assert.strictEqual(error.detail, "Source control operation failed."); + assert.strictEqual(error.message.includes("transport closed"), false); + assert.deepStrictEqual(error.cause, { + name: "Error", + message: "transport closed", + }); + }).pipe( + Effect.provide( + makeTestLayer( + () => Effect.fail(cause) as unknown as Effect.Effect, + ), + ), + ); + }); + + it.effect("keeps action wrapper messages structural while preserving sanitized causes", () => { + const cause = new Error("credential-bearing workflow failure"); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .commitStaged({ + cwd: "/repo", + message: "Test commit", + push: true, + }) + .pipe(Effect.flip); + + assert.strictEqual(isGitCommandError(error), true); + assert.strictEqual(error.operation, "vcs.panel.commitStaged.status"); + assert.strictEqual(error.detail, "Git command failed."); + assert.strictEqual(error.message.includes(cause.message), false); + assert.deepStrictEqual(error.cause, { + name: "Error", + message: cause.message, + }); + }).pipe( + Effect.provide( + makeTestLayer(() => Effect.succeed(success()), { + status: () => + Effect.fail(cause) as unknown as ReturnType, + }), + ), + ); + }); + + it.effect("cleans staged additions missing from HEAD without failing tracked paths", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.discardFiles({ + cwd: "/repo", + paths: ["new-file.ts"], + staged: true, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["--literal-pathspecs", "ls-tree", "-r", "--name-only", "HEAD", "--", "new-file.ts"], + ["--literal-pathspecs", "reset", "--", "new-file.ts"], + ["--literal-pathspecs", "clean", "-fd", "--", "new-file.ts"], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(""); + }), + ), + ), + ); + }); + + it.effect("discards mixed tracked and untracked unstaged files in one action", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.discardFiles({ + cwd: "/repo", + paths: ["tracked.ts", "new-file.ts"], + staged: false, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["--literal-pathspecs", "ls-files", "--cached", "--", "tracked.ts", "new-file.ts"], + ["--literal-pathspecs", "restore", "--worktree", "--", "tracked.ts"], + ["--literal-pathspecs", "clean", "-fd", "--", "tracked.ts", "new-file.ts"], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.discardUnstagedFiles.listIndexPaths" + ? success("tracked.ts\n") + : success(""); + }), + ), + ), + ); + }); + + it.effect("fails unstaged discard when tracked restore fails", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .discardFiles({ + cwd: "/repo", + paths: ["tracked.ts", "new-file.ts"], + staged: false, + }) + .pipe(Effect.flip); + + assert.equal(error.operation, "vcs.panel.discardUnstagedFiles"); + const relevantCalls = calls.filter((call) => + [ + "vcs.panel.discardUnstagedFiles.listIndexPaths", + "vcs.panel.discardUnstagedFiles", + "vcs.panel.cleanUntrackedFiles", + ].includes(call.operation), + ); + assert.deepStrictEqual( + relevantCalls.map((call) => [call.operation, call.args]), + [ + [ + "vcs.panel.discardUnstagedFiles.listIndexPaths", + ["--literal-pathspecs", "ls-files", "--cached", "--", "tracked.ts", "new-file.ts"], + ], + [ + "vcs.panel.discardUnstagedFiles", + ["--literal-pathspecs", "restore", "--worktree", "--", "tracked.ts"], + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + if (input.operation === "vcs.panel.discardUnstagedFiles.listIndexPaths") { + return success("tracked.ts\n"); + } + if (input.operation === "vcs.panel.discardUnstagedFiles") { + return failure("restore failed"); + } + return success(""); + }), + ), + ), + ); + }); + + it.effect("preserves multiline commit message formatting in one git argument", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.commitStaged({ + cwd: "/repo", + message: "Subject\nBody without blank separator", + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [["commit", "-m", "Subject\nBody without blank separator"]], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("does not abort a successful commit because of hook diagnostics", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.commitStaged({ + cwd: "/repo", + message: "Commit selected file", + }); + assert.deepStrictEqual( + calls.map((call) => call.args), + [["commit", "-m", "Commit selected file"]], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.gen(function* () { + calls.push(input); + yield* ( + input.progress?.onStderrLine?.("Error: Cannot find native binding") ?? Effect.void + ); + yield* ( + input.progress?.onStderrLine?.("VITE+ - pre-commit script failed (code 1)") ?? + Effect.void + ); + yield* Effect.yieldNow; + return success(); + }), + ), + ), + ); + }); + + it.effect("enriches a failed commit with detected hook diagnostics", () => { + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .commitStaged({ + cwd: "/repo", + message: "Commit selected file", + }) + .pipe(Effect.flip); + + assert.equal( + error.detail, + "The Git pre-commit hook could not load a required native dependency. Reinstall the repository dependencies and try again.", + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.gen(function* () { + yield* ( + input.progress?.onStderrLine?.("Error: Cannot find native binding") ?? Effect.void + ); + return failure("pre-commit hook failed"); + }), + ), + ), + ); + }); + + it.effect("stages and unstages selected files with literal pathspecs", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const paths = ["src/[literal].ts"]; + + yield* service.stageFiles({ cwd: "/repo", paths }); + yield* service.unstageFiles({ cwd: "/repo", paths }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["--literal-pathspecs", "add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"], + ["--literal-pathspecs", "reset", "--", "src/[literal].ts"], + ], + ); + assert.isTrue(calls.every((call) => call.timeoutMs === GIT_COMMAND_TIMEOUT_MS.local)); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("stashes selected files with literal pathspecs", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.createStash({ + cwd: "/repo", + paths: ["src/[literal].ts"], + includeUntracked: true, + message: "Save literal file", + }); + + assert.deepStrictEqual( + calls.filter((call) => call.operation === "vcs.panel.createStash").map((call) => call.args), + [ + [ + "--literal-pathspecs", + "stash", + "push", + "--include-untracked", + "-m", + "Save literal file", + "--", + "src/[literal].ts", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("commits selected files through an isolated temporary index", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.commitStaged({ + cwd: "/repo", + paths: ["src/mixed.ts"], + message: "Commit selected file", + }); + + assert.deepStrictEqual( + calls.map((call) => ({ operation: call.operation, args: call.args })), + [ + { + operation: "vcs.panel.commitStaged.tempIndexResolveHead", + args: ["rev-parse", "--verify", "HEAD"], + }, + { + operation: "vcs.panel.commitStaged.tempIndexReadTree", + args: ["read-tree", "HEAD"], + }, + { + operation: "vcs.panel.commitStaged.selectedDeletions", + args: ["diff", "--cached", "--name-only", "--diff-filter=D", "-z"], + }, + { + operation: "vcs.panel.commitStaged.tempIndexAddSelected", + args: [ + "--literal-pathspecs", + "add", + "-A", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + }, + { + operation: "vcs.panel.commitStaged", + args: ["commit", "-m", "Commit selected file"], + }, + { + operation: "vcs.panel.commitStaged.syncIndex", + args: [ + "--literal-pathspecs", + "reset", + "HEAD", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + }, + ], + ); + const selectedIndexCalls = calls.filter( + (call) => + call.operation.startsWith("vcs.panel.commitStaged") && + ![ + "vcs.panel.commitStaged.selectedDeletions", + "vcs.panel.commitStaged.syncIndex", + ].includes(call.operation), + ); + assert.isTrue(selectedIndexCalls.every((call) => Boolean(call.env?.GIT_INDEX_FILE?.length))); + assert.strictEqual( + calls.find((call) => call.operation === "vcs.panel.commitStaged")?.timeoutMs, + GIT_COMMAND_TIMEOUT_MS.commit, + ); + assert.isUndefined(calls.at(-1)?.env?.GIT_INDEX_FILE); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("keeps large literal selections off argv throughout a generated-message commit", () => { + const calls: ExecuteGitInput[] = []; + const paths = Array.from( + { length: 2500 }, + (_, index) => `plugins/${"long-directory/".repeat(5)}file [${index}].txt`, + ); + paths.push("literal\nnewline.txt", ":(glob)literal.txt"); + const stdin = `${paths.join("\0")}\0`; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + yield* service.commitStaged({ cwd: "/repo", paths }); + assert.isAbove(stdin.length, 32767); + const adds = calls.filter( + (call) => + call.args.includes("add") || call.operation === "vcs.panel.commitStaged.syncIndex", + ); + assert.lengthOf(adds, 2); + assert.deepStrictEqual( + adds.map((call) => call.stdin), + [stdin, stdin], + ); + assert.isString(adds[0]?.env?.GIT_INDEX_FILE); + assert.isUndefined(adds[1]?.env?.GIT_INDEX_FILE); + const messageReads = calls.filter((call) => + call.operation.startsWith("vcs.panel.commitMessage"), + ); + assert.lengthOf(messageReads, 2); + assert.isTrue( + messageReads.every((call) => call.env?.GIT_INDEX_FILE === adds[0]?.env?.GIT_INDEX_FILE), + ); + assert.isTrue(calls.every((call) => call.args.every((arg) => !paths.includes(arg)))); + assert.isTrue(calls.some((call) => call.operation === "vcs.panel.commitStaged")); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect( + "preserves selected staged deletions without re-adding their ignored working files", + () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + yield* service.commitStaged({ + cwd: "/repo", + paths: ["plugins/[ignored].php", ".gitignore"], + message: "Stop tracking plugin", + }); + const removal = calls.find( + (call) => call.operation === "vcs.panel.commitStaged.tempIndexRemoveSelected", + ); + assert.deepStrictEqual(removal?.args, ["update-index", "--force-remove", "-z", "--stdin"]); + assert.equal(removal?.stdin, "plugins/[ignored].php\0"); + assert.isString(removal?.env?.GIT_INDEX_FILE); + assert.equal( + calls.find((call) => call.operation === "vcs.panel.commitStaged.tempIndexAddSelected") + ?.stdin, + ".gitignore\0", + ); + const sync = calls.find((call) => call.operation === "vcs.panel.commitStaged.syncIndex"); + assert.deepStrictEqual(sync?.args, [ + "--literal-pathspecs", + "reset", + "HEAD", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ]); + assert.equal(sync?.stdin, "plugins/[ignored].php\0.gitignore\0"); + assert.isUndefined(sync?.env); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success( + input.operation === "vcs.panel.commitStaged.selectedDeletions" + ? "plugins/[ignored].php\0unselected.php\0" + : "", + ); + }), + ), + ), + ); + }, + ); + + it.effect("commits a selection containing only staged deletions without invoking add", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + yield* service.commitStaged({ + cwd: "/repo", + paths: ["removed.php"], + message: "Stop tracking file", + }); + assert.isFalse(calls.some((call) => call.args.includes("add"))); + assert.isTrue(calls.some((call) => call.operation === "vcs.panel.commitStaged")); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success( + input.operation === "vcs.panel.commitStaged.selectedDeletions" ? "removed.php\0" : "", + ); + }), + ), + ), + ); + }); + + it.effect("leaves the real index untouched when a selected-file commit fails", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service + .commitStaged({ + cwd: "/repo", + paths: ["src/mixed.ts"], + message: "Commit selected file", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + calls.map((call) => call.operation), + [ + "vcs.panel.commitStaged.tempIndexResolveHead", + "vcs.panel.commitStaged.tempIndexReadTree", + "vcs.panel.commitStaged.selectedDeletions", + "vcs.panel.commitStaged.tempIndexAddSelected", + "vcs.panel.commitStaged", + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.commitStaged" + ? failure("commit failed") + : success(); + }), + ), + ), + ); + }); + + it.effect("reconciles a selected-file commit before interrupted temporary-index cleanup", () => + Effect.gen(function* () { + const cleanupStarted = yield* Deferred.make(); + const releaseCleanup = yield* Deferred.make(); + const calls: string[] = []; + yield* Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const commit = yield* service + .commitStaged({ + cwd: "/repo", + paths: ["src/mixed.ts"], + message: "Commit selected file", + push: true, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(cleanupStarted); + const interruption = yield* Fiber.interrupt(commit).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(releaseCleanup, undefined); + yield* Fiber.join(interruption); + const exit = yield* Fiber.await(commit); + assert.isTrue(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)); + assert.deepStrictEqual(calls.slice(-3), [ + "vcs.panel.commitStaged", + "vcs.panel.commitStaged.syncIndex", + "cleanup", + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input.operation); + return success(); + }), + {}, + {}, + {}, + {}, + Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return FileSystem.FileSystem.of({ + ...fileSystem, + remove: (path, options) => + Effect.gen(function* () { + calls.push("cleanup"); + yield* Deferred.succeed(cleanupStarted, undefined); + yield* Deferred.await(releaseCleanup); + yield* fileSystem.remove(path, options); + }), + }); + }), + ).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), + ); + }), + ); + + it.effect( + "reports recovery and prevents push when real-index sync fails after committing", + () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .commitStaged({ + cwd: "/repo", + paths: ["src/[literal].ts"], + message: "Commit selected file", + push: true, + }) + .pipe(Effect.flip); + assert.equal(error._tag, "GitCommandError"); + assert.equal(error.operation, "vcs.panel.commitStaged.syncIndex"); + assert.match(error.detail, /commit was already created but was not pushed/); + assert.match(error.detail, /Do not commit these changes again/); + assert.match(error.detail, /git --literal-pathspecs reset HEAD -- /); + assert.match(error.detail, /quoting each pathname for your shell/); + assert.equal( + error.command, + "git --literal-pathspecs reset HEAD --pathspec-from-file=- --pathspec-file-nul", + ); + + assert.deepStrictEqual( + calls.map((call) => ({ operation: call.operation, args: call.args })), + [ + { + operation: "vcs.panel.commitStaged.tempIndexResolveHead", + args: ["rev-parse", "--verify", "HEAD"], + }, + { + operation: "vcs.panel.commitStaged.tempIndexReadTree", + args: ["read-tree", "HEAD"], + }, + { + operation: "vcs.panel.commitStaged.selectedDeletions", + args: ["diff", "--cached", "--name-only", "--diff-filter=D", "-z"], + }, + { + operation: "vcs.panel.commitStaged.tempIndexAddSelected", + args: [ + "--literal-pathspecs", + "add", + "-A", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + }, + { + operation: "vcs.panel.commitStaged", + args: ["commit", "-m", "Commit selected file"], + }, + { + operation: "vcs.panel.commitStaged.syncIndex", + args: [ + "--literal-pathspecs", + "reset", + "HEAD", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.commitStaged.syncIndex" + ? failure("index sync failed") + : success(); + }), + ), + ), + ); + }, + ); + + it.effect("passes merge refs after a positional separator", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.mergeBranchIntoCurrent({ + cwd: "/repo", + refName: "feature/source-control", + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [["merge", "--no-edit", "--", "feature/source-control"]], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("initializes an empty selected-file index for an unborn HEAD", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.commitStaged({ + cwd: "/repo", + paths: ["README.md"], + message: "Initial commit", + }); + + assert.deepStrictEqual( + calls.slice(0, 4).map((call) => ({ + operation: call.operation, + args: call.args, + allowNonZeroExit: call.allowNonZeroExit, + })), + [ + { + operation: "vcs.panel.commitStaged.tempIndexResolveHead", + args: ["rev-parse", "--verify", "HEAD"], + allowNonZeroExit: true, + }, + { + operation: "vcs.panel.commitStaged.tempIndexReadTree", + args: ["read-tree", "--empty"], + allowNonZeroExit: false, + }, + { + operation: "vcs.panel.commitStaged.selectedDeletions", + args: ["diff", "--cached", "--name-only", "--diff-filter=D", "-z"], + allowNonZeroExit: false, + }, + { + operation: "vcs.panel.commitStaged.tempIndexAddSelected", + args: [ + "--literal-pathspecs", + "add", + "-A", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + allowNonZeroExit: false, + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.commitStaged.tempIndexResolveHead" + ? failure("Needed a single revision") + : success(); + }), + ), + ), + ); + }); + + it.effect( + "reports recovery and prevents push when post-commit index synchronization defects", + () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .commitStaged({ + cwd: "/repo", + paths: ["src/selected.ts"], + message: "Commit selected file", + push: true, + }) + .pipe(Effect.flip); + assert.equal(error._tag, "GitCommandError"); + assert.equal(error.operation, "vcs.panel.commitStaged.syncIndex"); + assert.match(error.detail, /commit was already created but was not pushed/); + assert.match(error.detail, /Do not commit these changes again/); + assert.match(error.detail, /git --literal-pathspecs reset HEAD -- /); + assert.match(error.detail, /quoting each pathname for your shell/); + assert.equal( + error.command, + "git --literal-pathspecs reset HEAD --pathspec-from-file=- --pathspec-file-nul", + ); + assert.equal(calls.at(-1)?.operation, "vcs.panel.commitStaged.syncIndex"); + }).pipe( + Effect.provide( + makeTestLayer((input) => { + calls.push(input); + return input.operation === "vcs.panel.commitStaged.syncIndex" + ? Effect.die(new Error("index sync defect")) + : Effect.succeed(success()); + }), + ), + ); + }, + ); + + it.effect("scopes generated stash input to literal selected paths", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.createStash({ + cwd: "/repo", + mode: "all", + paths: ["src/[literal].ts"], + includeUntracked: true, + }); + + assert.deepStrictEqual( + calls + .filter((call) => call.operation !== "vcs.panel.resolveGitCommonDir") + .map((call) => ({ operation: call.operation, args: call.args })), + [ + { + operation: "vcs.panel.stashMessageSummary", + args: ["--literal-pathspecs", "diff", "HEAD", "--stat", "--", "src/[literal].ts"], + }, + { + operation: "vcs.panel.stashMessagePatch", + args: [ + "--literal-pathspecs", + "diff", + "HEAD", + "--no-ext-diff", + "--patch", + "--minimal", + "--", + "src/[literal].ts", + ], + }, + { + operation: "vcs.panel.stashMessageStatus", + args: ["--literal-pathspecs", "status", "--short", "--", "src/[literal].ts"], + }, + { + operation: "vcs.panel.createStash", + args: [ + "--literal-pathspecs", + "stash", + "push", + "--include-untracked", + "-m", + "T3 Code all stash", + "--", + "src/[literal].ts", + ], + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(""); + }), + ), + ), + ); + }); + + it.effect("reverses comparisons with the working tree on the left", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.compare({ + cwd: "/repo", + left: { kind: "working-tree" }, + right: { kind: "branch", refName: "feature/right" }, + }); + yield* service.compare({ + cwd: "/repo", + left: { kind: "branch", refName: "feature/left" }, + right: { kind: "working-tree" }, + }); + yield* service.compare({ + cwd: "/repo", + left: { kind: "working-tree" }, + right: { kind: "working-tree" }, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", + "--minimal", + "--reverse", + "feature/right", + ], + [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", + "--minimal", + "feature/left", + ], + [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success("patch"); + }), + ), + ), + ); + }); + + it("classifies only ref-affecting panel actions for shared ref invalidation", () => { + assert.deepStrictEqual(SOURCE_CONTROL_PANEL_REF_AFFECTING_ACTION_METHODS, [ + "commitStaged", + "pullBranch", + "pushBranch", + "deleteBranch", + "undoLatestCommit", + "revertCommit", + "checkoutCommit", + "createBranchFromCommit", + "mergeBranchIntoCurrent", + "rebaseCurrentOnto", + "fetchBranch", + "fetchRemote", + "addRemote", + "removeRemote", + ]); + }); + + it.effect("invalidates shared refs only after attempted ref mutations", () => { + const invalidations: string[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.createBranchFromCommit({ + cwd: "/repo", + sha: "abc123", + branchName: "feature/success", + }); + assert.deepStrictEqual(invalidations, ["/repo"]); + + const failed = yield* Effect.exit( + service.createBranchFromCommit({ + cwd: "/repo", + sha: "abc123", + branchName: "feature/failure", + }), + ); + assert(Exit.isFailure(failed)); + assert.deepStrictEqual(invalidations, ["/repo", "/repo"]); + + yield* service.fetchAllRemotes({ cwd: "/repo", force: true }); + assert.deepStrictEqual(invalidations, ["/repo", "/repo", "/repo"]); + + yield* service.fetchAllRemotes({ cwd: "/repo" }); + assert.deepStrictEqual(invalidations, ["/repo", "/repo", "/repo"]); + + const validationFailure = yield* Effect.exit( + service.addRemote({ + cwd: "/repo", + name: "-invalid", + url: "https://example.com/repo.git", + }), + ); + assert(Exit.isFailure(validationFailure)); + assert.deepStrictEqual(invalidations, ["/repo", "/repo", "/repo"]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.succeed( + input.operation === "vcs.panel.resolveGitCommonDir" + ? success("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/repo/.git") + : input.args.includes("feature/failure") + ? failure("branch creation failed") + : success(), + ), + {}, + {}, + {}, + { + invalidateRefs: (cwd) => + Effect.sync(() => { + invalidations.push(cwd); + }), + }, + ), + ), + ); + }); + + it.effect("does not invalidate shared refs for working-tree-only panel actions", () => { + const invalidations: string[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.stageFiles({ cwd: "/repo", paths: ["file.ts"] }); + + assert.deepStrictEqual(invalidations, []); + }).pipe( + Effect.provide( + makeTestLayer( + () => Effect.succeed(success()), + {}, + {}, + {}, + { + invalidateRefs: (cwd) => + Effect.sync(() => { + invalidations.push(cwd); + }), + }, + ), + ), + ); + }); +}); + +describe("lazy commit files", () => { + it.effect("keeps history lightweight and shares immutable file reads", () => { + const calls: ExecuteGitInput[] = []; + const sha = "a".repeat(40); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const page = yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + kind: "history", + skip: 0, + limit: 10, + deferCommitFiles: true, + }); + assert.equal(page.commits[0]?.filesDeferred, true); + assert.deepStrictEqual(page.commits[0]?.files, []); + assert.isFalse(calls.some((call) => call.operation === "vcs.panel.commitNumstat")); + const [first, second] = yield* Effect.all( + [service.commitFiles({ cwd: "/repo", sha }), service.commitFiles({ cwd: "/repo", sha })], + { concurrency: "unbounded" }, + ); + assert.equal(first.files.length, 1); + assert.deepStrictEqual(first, second); + yield* service.commitFiles({ cwd: "/repo", sha }); + assert.equal(calls.filter((call) => call.operation === "vcs.panel.commitNumstat").length, 1); + yield* service.commitFiles({ cwd: "/other-repo", sha }); + assert.equal(calls.filter((call) => call.operation === "vcs.panel.commitNumstat").length, 2); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + if (input.args[0] === "rev-list") return success("1"); + if (input.args[0] === "log") + return success( + [ + sha, + "aaaaaaa", + "Author", + "author@example.test", + "2026-09-09T00:00:00Z", + "Large commit", + ].join("\t"), + ); + if (input.operation === "vcs.panel.commitNumstat") return success("1\t0\tfile.txt\0"); + if (input.operation === "vcs.panel.commitNameStatus") return success("M\0file.txt\0"); + return success(); + }), + ), + ), + ); + }); + it.effect("retries file reads after a Git failure", () => { + let fail = true; + const sha = "b".repeat(40); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const failed = yield* service.commitFiles({ cwd: "/repo", sha }).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(failed)); + fail = false; + const files = yield* service.commitFiles({ cwd: "/repo", sha }); + assert.deepStrictEqual(files.files, []); + }).pipe( + Effect.provide( + makeTestLayer(() => Effect.sync(() => (fail ? failure("read failed") : success()))), + ), + ); + }); +}); + +it.effect("shares repository scans across enrichment batches", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + yield* Effect.all( + [ + service.enrichWorkingTreeFiles({ cwd: "/repo", paths: ["first.txt"] }), + service.enrichWorkingTreeFiles({ cwd: "/repo", paths: ["second.txt"] }), + ], + { concurrency: "unbounded" }, + ); + assert.equal( + calls.filter((call) => call.operation === "vcs.panel.enrichWorkingTreeFiles.statusPorcelain") + .length, + 1, + ); + assert.equal( + calls.filter((call) => call.operation === "vcs.panel.enrichWorkingTreeFiles.unstagedNumstat") + .length, + 1, + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelService.Diffs.test.ts b/apps/server/src/sourceControl/SourceControlPanelService.Diffs.test.ts new file mode 100644 index 000000000000..30bf6f938ef2 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.Diffs.test.ts @@ -0,0 +1,1499 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + GitCommandError, + SourceControlProviderError, + type ChangeRequest, + type SourceControlProviderKind, + type VcsRef, + type VcsStatusLocalResult, +} from "@t3tools/contracts"; + +import { + SourceControlPanelService, + layer as SourceControlPanelServiceLayer, +} from "./SourceControlPanelService.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { GitManager } from "../git/GitManager.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { GitVcsDriver, type ExecuteGitInput, type ExecuteGitResult } from "../vcs/GitVcsDriver.ts"; + +const branchRef: VcsRef = { + name: "feature/source-control", + current: false, + isDefault: false, + worktreePath: null, +}; +const isGitCommandError = Schema.is(GitCommandError); + +const success = (stdout = ""): ExecuteGitResult => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const failure = (stderr: string): ExecuteGitResult => ({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr, + stdoutTruncated: false, + stderrTruncated: false, +}); + +const emptyProvider = SourceControlProvider.SourceControlProvider.of({ + kind: "unknown", + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getChangeRequest", + cwd: "/repo", + detail: "get change request not stubbed", + }), + ), + createChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createChangeRequest", + cwd: "/repo", + detail: "create change request not stubbed", + }), + ), + getRepositoryCloneUrls: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getRepositoryCloneUrls", + cwd: "/repo", + detail: "repository clone URLs not stubbed", + }), + ), + getCommitAvatarUrl: () => Effect.succeed(null), + createRepository: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createRepository", + cwd: "/repo", + detail: "create repository not stubbed", + }), + ), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.checkoutChangeRequest", + cwd: "/repo", + detail: "checkout change request not stubbed", + }), + ), +}); + +function makeTestLayer( + execute: (input: ExecuteGitInput) => Effect.Effect, + workflow: Partial = {}, + providers: Partial< + Record + > = {}, + settings: Parameters[0] = {}, +) { + return SourceControlPanelServiceLayer.pipe( + Layer.provide(Layer.mock(GitManager)({ branchPullRequest: () => Effect.succeed(null) })), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest(settings)), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + }), + ), + Layer.provide( + Layer.succeed(GitWorkflowService, { + status: (input) => + workflow.status + ? workflow.status(input) + : workflow.localStatus + ? workflow.localStatus(input).pipe( + Effect.map((status) => ({ + ...status, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: + (status as { readonly aheadOfDefaultCount?: number }).aheadOfDefaultCount ?? + 0, + pr: null, + })), + ) + : Effect.fail( + new GitCommandError({ + operation: "test.status", + command: "git status", + cwd: "/repo", + detail: "status not stubbed", + }), + ), + localStatus: () => + Effect.fail( + new GitCommandError({ + operation: "test.localStatus", + command: "git status", + cwd: "/repo", + detail: "local status not stubbed", + }), + ), + pullCurrentBranch: () => + Effect.fail( + new GitCommandError({ + operation: "test.pullCurrentBranch", + command: "git pull", + cwd: "/repo", + detail: "pull not stubbed", + }), + ), + ...workflow, + } as GitWorkflowService["Service"]), + ), + Layer.provide( + Layer.succeed(GitVcsDriver, { + execute, + invalidateRefs: () => Effect.void, + } as unknown as GitVcsDriver["Service"]), + ), + Layer.provide( + Layer.succeed( + SourceControlProviderRegistry, + SourceControlProviderRegistry.of({ + resolveLink: () => Effect.die("unused resolveLink"), + get: (kind) => Effect.succeed(providers[kind] ?? emptyProvider), + resolveHandle: () => Effect.succeed({ provider: emptyProvider, context: null }), + resolve: () => Effect.succeed(emptyProvider), + discover: Effect.succeed([]), + }), + ), + ), + ); +} + +const localStatus: VcsStatusLocalResult = { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/source-control", + hasWorkingTreeChanges: true, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, +}; + +describe("SourceControlPanelService", () => { + it.effect("rejects option-shaped revisions and remotes before launching Git", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const cwd = "/repo"; + const invalid = "--output=unexpected.patch"; + const operations = [ + service.revertCommit({ cwd, sha: "--abort" }), + service.checkoutCommit({ cwd, sha: "--force" }), + service.undoLatestCommit({ cwd, sha: invalid }), + service.undoLatestCommit({ cwd, branchName: invalid }), + service.fetchRemote({ cwd, remoteName: "--upload-pack=unexpected" }), + service.pushBranch({ cwd, branchName: "--receive-pack=unexpected" }), + service.pushBranch({ cwd, branchName: "main", remoteName: "--receive-pack=unexpected" }), + service.applyStash({ cwd, stashRef: "--index" }), + service.popStash({ cwd, stashRef: "--index" }), + service.dropStash({ cwd, stashRef: invalid }), + service.compare({ + cwd, + left: { kind: "branch", refName: invalid }, + right: { kind: "working-tree" }, + }), + service.compare({ + cwd, + left: { kind: "branch", refName: "main" }, + right: { kind: "stash", refName: invalid }, + }), + service.readFileDiff({ + cwd, + path: "file.ts", + source: { kind: "commit", sha: invalid }, + }), + service.readFileDiff({ + cwd, + path: "file.ts", + source: { kind: "stash", stashRef: invalid }, + }), + service.readFileDiff({ + cwd, + path: "file.ts", + source: { kind: "compare", baseRef: invalid, refName: "main" }, + }), + service.readFileDiff({ + cwd, + path: "file.ts", + source: { kind: "compare", baseRef: "main", refName: invalid }, + }), + ]; + for (const operation of operations) { + const error = yield* Effect.flip(operation); + assert.isTrue(isGitCommandError(error)); + } + assert.deepStrictEqual(calls, []); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("passes rebase refs after a positional separator", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.rebaseCurrentOnto({ + cwd: "/repo", + refName: "feature/source-control", + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [["rebase", "--", "feature/source-control"]], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("publishes to an explicit remote without consulting the upstream", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + yield* service.pushBranch({ cwd: "/repo", branchName: "feature", remoteName: "upstream" }); + assert.deepStrictEqual( + calls.map((call) => call.args), + [["push", "-u", "upstream", "refs/heads/feature:refs/heads/feature"]], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("pushes a plus-prefixed branch without forcing or selecting another source", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + yield* service.pushBranch({ cwd: "/repo", branchName: "+main", force: false }); + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "+main@{upstream}"], + ["push", "-u", "origin", "refs/heads/+main:refs/heads/+main"], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.branchUpstream" + ? failure("no upstream") + : success(); + }), + ), + ), + ); + }); + + it.effect("sets upstream when force-pushing an unpublished branch", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.pushBranch({ + cwd: "/repo", + branchName: "feature/source-control", + force: true, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "feature/source-control@{upstream}", + ], + [ + "push", + "--force-with-lease", + "-u", + "origin", + "refs/heads/feature/source-control:refs/heads/feature/source-control", + ], + ], + ); + assert.strictEqual( + calls.find((call) => call.operation === "vcs.panel.pushBranch")?.timeoutMs, + 300_000, + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.upstreamForRef" + ? failure("no upstream") + : success(); + }), + ), + ), + ); + }); + + it.effect( + "publishes to the local branch name when the configured upstream is only a base ref", + () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.pushBranch({ + cwd: "/repo", + branchName: "split/vscode-extension-work", + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "split/vscode-extension-work@{upstream}", + ], + ["remote"], + [ + "push", + "-u", + "origin", + "refs/heads/split/vscode-extension-work:refs/heads/split/vscode-extension-work", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.branchUpstream": + return success("upstream/main\n"); + case "vcs.panel.pushBranch.remotes": + return success("origin\nupstream\n"); + default: + return success(); + } + }), + ), + ), + ); + }, + ); + + it.effect("keeps staged rename stats keyed by the destination path", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + const stagedFiles = + snapshot.changeGroups.find((group) => group.kind === "staged")?.files ?? []; + + assert.deepStrictEqual(stagedFiles, [ + { + path: "src/new.ts", + originalPath: "src/old.ts", + status: "renamed", + insertions: 3, + deletions: 1, + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + case "vcs.panel.remotes": + case "vcs.panel.stashes": + return success(""); + case "vcs.panel.statusPorcelain": + return success( + [ + "# branch.oid abc", + "# branch.head feature/source-control", + "2 R. N... 100644 100644 100644 abc abc R100 src/new.ts\tsrc/old.ts", + ].join("\n"), + ); + case "vcs.panel.stagedNumstat": + return success("3\t1\t\0src/old.ts\0src/new.ts\0"); + case "vcs.panel.stagedNameStatus": + return success("R100\0src/old.ts\0src/new.ts\0"); + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ), + ); + + it.effect("reads staged rename diffs against the original path", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.readFileDiff({ + cwd: "/repo", + path: "src/new.ts", + originalPath: "src/old.ts", + source: { kind: "working-tree", staged: true }, + }); + + assert.equal(result.patch, "rename patch"); + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + [ + "diff", + "--cached", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", + "--minimal", + "--find-renames=20%", + "--", + "src/old.ts", + "src/new.ts", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success("rename patch"); + }), + ), + ), + ); + }); + + it.effect("disables user-configured diff rendering for untracked files", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.readFileDiff({ + cwd: "/repo", + path: "src/untracked.ts", + source: { kind: "working-tree", staged: false }, + }); + + assert.equal(result.patch, "untracked patch"); + assert.deepStrictEqual( + calls.map((call) => ({ + operation: call.operation, + args: call.args, + allowNonZeroExit: call.allowNonZeroExit, + })), + [ + { + operation: "vcs.panel.readFileDiff", + args: [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", + "--minimal", + "--find-renames=20%", + "--", + "src/untracked.ts", + ], + allowNonZeroExit: false, + }, + { + operation: "vcs.panel.readFileDiff.untrackedPaths", + args: ["ls-files", "--others", "--exclude-standard", "-z", "--", "src/untracked.ts"], + allowNonZeroExit: false, + }, + { + operation: "vcs.panel.readUntrackedFileDiff", + args: [ + "diff", + "--no-index", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", + "--", + "/dev/null", + "src/untracked.ts", + ], + allowNonZeroExit: true, + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + if (input.operation === "vcs.panel.readFileDiff.untrackedPaths") { + return success("src/untracked.ts\0"); + } + return input.operation === "vcs.panel.readUntrackedFileDiff" + ? success("untracked patch") + : success(""); + }), + ), + ), + ); + }); + + for (const path of ["README.md", ".env", "../outside.ts", "/outside.ts"]) { + it.effect(`does not render ${path} as added when Git does not list it as untracked`, () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const result = yield* service.readFileDiff({ + cwd: "/repo", + path, + source: { kind: "working-tree", staged: false }, + }); + assert.equal(result.patch, ""); + assert.isFalse(calls.some((call) => call.operation === "vcs.panel.readUntrackedFileDiff")); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + } + + it.effect("keeps cwd-relative untracked diffs above a subdirectory panel", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const result = yield* service.readFileDiff({ + cwd: "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/repo/src", + path: "../new-file.ts", + source: { kind: "working-tree", staged: false }, + }); + assert.equal(result.patch, "untracked patch"); + const fallback = calls.find((call) => call.operation === "vcs.panel.readUntrackedFileDiff"); + assert.equal(fallback?.cwd, "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/repo/src"); + assert.equal(fallback?.args.at(-1), "../new-file.ts"); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + if (input.operation === "vcs.panel.readFileDiff.untrackedPaths") { + return success("../new-file.ts\0"); + } + return input.operation === "vcs.panel.readUntrackedFileDiff" + ? success("untracked patch") + : success(); + }), + ), + ), + ); + }); + + it.effect("disables user-configured rendering for ref-backed Review patches", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.readFileDiff({ + cwd: "/repo", + path: "src/commit.ts", + source: { kind: "commit", sha: "abc123" }, + }); + yield* service.readFileDiff({ + cwd: "/repo", + path: "src/compare.ts", + source: { + kind: "compare", + baseRef: "main", + refName: "feature/source-control", + }, + }); + + assert.deepStrictEqual( + calls.map((call) => ({ operation: call.operation, args: call.args })), + [ + { + operation: "vcs.panel.readCommitFileDiff", + args: [ + "show", + "--format=", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", + "--minimal", + "abc123", + "--", + "src/commit.ts", + ], + }, + { + operation: "vcs.panel.readCompareFileDiff", + args: [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", + "--minimal", + "main...feature/source-control", + "--", + "src/compare.ts", + ], + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success("patch"); + }), + ), + ), + ); + }); + + it.effect("reads tracked stash file diffs against the stash base", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.readFileDiff({ + cwd: "/repo", + path: "src/new.ts", + originalPath: "src/old.ts", + source: { kind: "stash", stashRef: "stash@{0}" }, + }); + + assert.equal(result.patch, "stash patch"); + assert.deepStrictEqual( + calls.map((call) => ({ operation: call.operation, args: call.args })), + [ + { + operation: "vcs.panel.readStashFileDiff", + args: [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", + "--minimal", + "--find-renames=20%", + "stash@{0}^1", + "stash@{0}", + "--", + "src/old.ts", + "src/new.ts", + ], + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success("stash patch"); + }), + ), + ), + ); + }); + + it.effect("falls back to the stash untracked parent for untracked files", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.readFileDiff({ + cwd: "/repo", + path: "src/untracked.ts", + source: { kind: "stash", stashRef: "stash@{1}" }, + }); + + assert.equal(result.patch, "untracked stash patch"); + assert.deepStrictEqual( + calls.map((call) => ({ + operation: call.operation, + args: call.args, + allowNonZeroExit: call.allowNonZeroExit, + })), + [ + { + operation: "vcs.panel.readStashFileDiff", + args: [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", + "--minimal", + "--find-renames=20%", + "stash@{1}^1", + "stash@{1}", + "--", + "src/untracked.ts", + ], + allowNonZeroExit: false, + }, + { + operation: "vcs.panel.readStashUntrackedFileDiff", + args: [ + "show", + "--format=", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", + "--minimal", + "stash@{1}^3", + "--", + "src/untracked.ts", + ], + allowNonZeroExit: true, + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.readStashUntrackedFileDiff" + ? success("untracked stash patch") + : success(""); + }), + ), + ), + ); + }); + + it.effect("reads unstaged rename diffs with a temporary intent-to-add index", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.readFileDiff({ + cwd: "/repo", + path: "src/new.ts", + originalPath: "src/old.ts", + source: { kind: "working-tree", staged: false }, + }); + + assert.equal(result.patch, "rename patch"); + assert.deepStrictEqual( + calls.map((call) => ({ operation: call.operation, args: call.args })), + [ + { + operation: "vcs.panel.readFileDiff.gitIndexPath", + args: ["rev-parse", "--git-path", "index"], + }, + { + operation: "vcs.panel.readFileDiff.tempIndexReadTree", + args: ["read-tree", "HEAD"], + }, + { + operation: "vcs.panel.readFileDiff.tempIndexIntentToAdd", + args: [ + "--literal-pathspecs", + "add", + "-N", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + }, + { + operation: "vcs.panel.readFileDiff", + args: [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", + "--unified=3", + "--inter-hunk-context=0", + "--minimal", + "--find-renames=20%", + "--", + "src/old.ts", + "src/new.ts", + ], + }, + ], + ); + const diffCall = calls.find((call) => call.operation === "vcs.panel.readFileDiff"); + assert.equal(Boolean(diffCall?.env?.GIT_INDEX_FILE?.length), true); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.readFileDiff.gitIndexPath": + return success("/tmp/t3-code-test-missing-index"); + case "vcs.panel.readFileDiff": + return success("rename patch"); + default: + return success(""); + } + }), + ), + ), + ); + }); + + it.effect("does not render unstaged rename diff fallbacks as new untracked files", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.readFileDiff({ + cwd: "/repo", + path: "src/new.ts", + originalPath: "src/old.ts", + source: { kind: "working-tree", staged: false }, + }); + + assert.equal(result.patch, ""); + assert.isFalse(calls.some((call) => call.operation === "vcs.panel.readUntrackedFileDiff")); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.readFileDiff.gitIndexPath": + return success("/tmp/t3-code-test-missing-index"); + default: + return success(""); + } + }), + ), + ), + ); + }); + + it.effect("decodes quoted porcelain paths and keeps mixed unstaged rows in snapshots", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + const unstagedFiles = + snapshot.changeGroups.find((group) => group.kind === "unstaged")?.files ?? []; + + assert.equal(snapshot.status.aheadOfDefaultCount, 4); + assert.deepStrictEqual(unstagedFiles, [ + { + path: "src/áudio.ts", + originalPath: null, + status: "untracked", + insertions: 0, + deletions: 0, + }, + { + path: "src/mixed.ts", + originalPath: null, + status: "modified", + insertions: 2, + deletions: 1, + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + case "vcs.panel.remotes": + case "vcs.panel.stashes": + return success(""); + case "vcs.panel.statusPorcelain": + return success( + [ + "# branch.oid abc", + "# branch.head feature/source-control", + "# branch.ab +2 -0", + "1 MM N... 100644 100644 100644 abc abc src/mixed.ts", + '? "src/\\303\\241udio.ts"', + ].join("\n"), + ); + case "vcs.panel.stagedNumstat": + return success("1\t0\tsrc/mixed.ts\0"); + case "vcs.panel.stagedNameStatus": + return success("M\0src/mixed.ts\0"); + case "vcs.panel.unstagedNumstat": + return success("2\t1\tsrc/mixed.ts\0"); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + aheadOfDefaultCount: 4, + }), + }, + ), + ), + ), + ); + + it.effect("matches working-tree statistics to paths when opened below the repository root", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const snapshot = yield* service.snapshot({ cwd: "/repo/apps/server" }); + const unstaged = snapshot.changeGroups.find((group) => group.kind === "unstaged")?.files; + const staged = snapshot.changeGroups.find((group) => group.kind === "staged")?.files; + assert.deepStrictEqual(unstaged, [ + { + path: "../web/changed.ts", + originalPath: null, + status: "modified", + insertions: 8, + deletions: 3, + }, + { path: "inside.ts", originalPath: null, status: "modified", insertions: 2, deletions: 1 }, + ]); + assert.deepStrictEqual(staged, [ + { + path: "../web/renamed.ts", + originalPath: "../web/original.ts", + status: "renamed", + insertions: 4, + deletions: 2, + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.workingTreePrefix": + return success("apps/server/\n"); + case "vcs.panel.statusPorcelain": + return success( + [ + "# branch.head main", + "1 .M N... 100644 100644 100644 abc abc ../web/changed.ts", + "1 .M N... 100644 100644 100644 abc abc inside.ts", + "2 R. N... 100644 100644 100644 abc abc R080 ../web/renamed.ts\t../web/original.ts", + ].join("\n"), + ); + case "vcs.panel.unstagedNumstat": + return success("8\t3\tapps/web/changed.ts\0" + "2\t1\tapps/server/inside.ts\0"); + case "vcs.panel.stagedNumstat": + return success("4\t2\t\0apps/web/original.ts\0apps/web/renamed.ts\0"); + case "vcs.panel.stagedNameStatus": + return success("R080\0apps/web/original.ts\0apps/web/renamed.ts\0"); + default: + return success(); + } + }), + { localStatus: () => Effect.succeed(localStatus) }, + ), + ), + ), + ); + + it.effect("keeps enrichment rename paths and deletion statistics relative to a nested cwd", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const result = yield* service.enrichWorkingTreeFiles({ + cwd: "/repo/apps/server", + paths: ["../web/new.ts", "../web/old.ts", "../web/deleted.ts"], + }); + assert.deepStrictEqual(result, { + files: [ + { + path: "../web/deleted.ts", + originalPath: null, + status: "deleted", + insertions: 0, + deletions: 7, + }, + { + path: "../web/new.ts", + originalPath: "../web/old.ts", + status: "renamed", + insertions: 2, + deletions: 1, + }, + ], + hiddenPaths: ["../web/old.ts"], + }); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.workingTreePrefix": + return success("apps/server/\n"); + case "vcs.panel.enrichWorkingTreeFiles.statusPorcelain": + return success( + [ + "1 .D N... 100644 100644 000000 abc abc ../web/deleted.ts", + "1 .D N... 100644 100644 000000 abc abc ../web/old.ts", + "? ../web/new.ts", + ].join("\n"), + ); + case "vcs.panel.enrichWorkingTreeFiles.unstagedNumstat": + return success("0\t7\tapps/web/deleted.ts\0" + "0\t9\tapps/web/old.ts\0"); + case "vcs.panel.enrichWorkingTreeFiles.untrackedNumstat": + return success("10\t0\t../web/new.ts\0"); + case "vcs.panel.unstagedNameStatusWithUntracked": + return success("R080\0apps/web/old.ts\0apps/web/new.ts\0"); + case "vcs.panel.unstagedNumstatWithUntracked": + return success("2\t1\t\0apps/web/old.ts\0apps/web/new.ts\0"); + default: + return success(); + } + }), + ), + ), + ), + ); + + it.effect("enriches visible untracked files with stats and rename matches", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.enrichWorkingTreeFiles({ + cwd: "/repo", + paths: ["blast-review/SKILL.md", "blast-review/agents/openai.yaml"], + }); + + assert.deepStrictEqual(result, { + hiddenPaths: ["copilot-blast-review/agents/openai.yaml", "copilot-blast-review/SKILL.md"], + files: [ + { + path: "blast-review/agents/openai.yaml", + originalPath: "copilot-blast-review/agents/openai.yaml", + status: "renamed", + insertions: 6, + deletions: 1, + }, + { + path: "blast-review/SKILL.md", + originalPath: "copilot-blast-review/SKILL.md", + status: "renamed", + insertions: 2, + deletions: 1, + }, + ], + }); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.enrichWorkingTreeFiles.statusPorcelain": + assert.deepStrictEqual(input.args, [ + "-c", + "status.relativePaths=true", + "status", + "--porcelain=2", + "--branch", + "-uall", + ]); + return success( + [ + "# branch.oid abc", + "# branch.head main", + "1 .D N... 100644 100644 000000 abc abc copilot-blast-review/SKILL.md", + "? blast-review/SKILL.md", + "? blast-review/agents/openai.yaml", + "? blast-review/scripts/blast-review.ts", + ].join("\n"), + ); + case "vcs.panel.enrichWorkingTreeFiles.unstagedNumstat": + return success("0\t20\tcopilot-blast-review/SKILL.md\n"); + case "vcs.panel.enrichWorkingTreeFiles.untrackedNumstat": { + const path = input.args.at(-1); + if (path === "blast-review/SKILL.md") { + return success("21\t0\t\0/dev/null\0blast-review/SKILL.md\0"); + } + if (path === "blast-review/agents/openai.yaml") { + return success("6\t0\t\0/dev/null\0blast-review/agents/openai.yaml\0"); + } + return success(""); + } + case "vcs.panel.gitIndexPath": + return success("/tmp/t3-code-test-missing-index"); + case "vcs.panel.tempIndexReadTree": + case "vcs.panel.tempIndexIntentToAdd": + return success(""); + case "vcs.panel.unstagedNameStatusWithUntracked": + return success( + [ + "R043", + "copilot-blast-review/SKILL.md", + "blast-review/SKILL.md", + "R035", + "copilot-blast-review/agents/openai.yaml", + "blast-review/agents/openai.yaml", + "", + ].join("\0"), + ); + case "vcs.panel.unstagedNumstatWithUntracked": + return success( + [ + "2\t1\t", + "copilot-blast-review/SKILL.md", + "blast-review/SKILL.md", + "6\t1\t", + "copilot-blast-review/agents/openai.yaml", + "blast-review/agents/openai.yaml", + "", + ].join("\0"), + ); + default: + return success(""); + } + }), + ), + ), + ), + ); + + it.effect("streams large rename candidate sets without putting paths on the command line", () => { + const paths = Array.from( + { length: 2500 }, + (_, index) => `public/plugins/${"long-directory-".repeat(5)}${index}/file with spaces.ts`, + ); + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + yield* service.enrichWorkingTreeFiles({ cwd: "/repo", paths: ["removed.ts"] }); + const add = calls.find((call) => call.operation === "vcs.panel.tempIndexIntentToAdd"); + assert.isDefined(add); + assert.deepStrictEqual(add?.args, [ + "--literal-pathspecs", + "add", + "-N", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ]); + assert.equal(add?.stdin, `${paths.join("\0")}\0`); + assert.isAbove(add!.stdin!.length, 32767); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success( + input.operation === "vcs.panel.enrichWorkingTreeFiles.statusPorcelain" + ? [ + "1 .D N... 100644 100644 000000 abc abc removed.ts", + ...paths.map((path) => `? ${path}`), + ].join("\n") + : "", + ); + }), + ), + ), + ); + }); + + it.effect("uses all untracked destinations when enriching a visible deleted source", () => { + const calls: ExecuteGitInput[] = []; + + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.enrichWorkingTreeFiles({ + cwd: "/repo", + paths: ["copilot-blast-review/SKILL.md"], + }); + + assert.deepStrictEqual(result.files, [ + { + path: "blast-review/SKILL.md", + originalPath: "copilot-blast-review/SKILL.md", + status: "renamed", + insertions: 2, + deletions: 1, + }, + ]); + assert.deepStrictEqual(result.hiddenPaths, ["copilot-blast-review/SKILL.md"]); + assert.deepStrictEqual( + calls.find((call) => call.operation === "vcs.panel.tempIndexIntentToAdd")?.args, + ["--literal-pathspecs", "add", "-N", "--pathspec-from-file=-", "--pathspec-file-nul"], + ); + assert.equal( + calls.find((call) => call.operation === "vcs.panel.tempIndexIntentToAdd")?.stdin, + "blast-review/SKILL.md\0blast-review/agents/openai.yaml\0blast-review/scripts/blast-review.ts\0", + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.enrichWorkingTreeFiles.statusPorcelain": + return success( + [ + "# branch.oid abc", + "# branch.head main", + "1 .D N... 100644 100644 000000 abc abc copilot-blast-review/SKILL.md", + "? blast-review/SKILL.md", + "? blast-review/agents/openai.yaml", + "? blast-review/scripts/blast-review.ts", + ].join("\n"), + ); + case "vcs.panel.enrichWorkingTreeFiles.unstagedNumstat": + return success("0\t20\tcopilot-blast-review/SKILL.md\n"); + case "vcs.panel.gitIndexPath": + return success("/tmp/t3-code-test-missing-index"); + case "vcs.panel.tempIndexReadTree": + case "vcs.panel.tempIndexIntentToAdd": + return success(""); + case "vcs.panel.unstagedNameStatusWithUntracked": + return success( + [ + "R043", + "copilot-blast-review/SKILL.md", + "blast-review/SKILL.md", + "R035", + "copilot-blast-review/agents/openai.yaml", + "blast-review/agents/openai.yaml", + "", + ].join("\0"), + ); + case "vcs.panel.unstagedNumstatWithUntracked": + return success( + [ + "2\t1\t", + "copilot-blast-review/SKILL.md", + "blast-review/SKILL.md", + "6\t1\t", + "copilot-blast-review/agents/openai.yaml", + "blast-review/agents/openai.yaml", + "", + ].join("\0"), + ); + default: + return success(""); + } + }), + ), + ), + ); + }); + + it.effect("infers line-based numstat renames when name-status is missing", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "history", + skip: 0, + limit: 10, + }); + + assert.deepStrictEqual(result.commits[0]?.files, [ + { + path: "new-name.ts", + originalPath: "old-name.ts", + status: "renamed", + insertions: 3, + deletions: 1, + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.branchCommitCount": + return success("1"); + case "vcs.panel.branchCommits": + return success( + "abc123\tabc123\tAda\tada@example.test\t2026-06-20T12:00:00.000Z\tRename file", + ); + case "vcs.panel.commitRefs": + case "vcs.panel.commitNameStatus": + return success(""); + case "vcs.panel.commitNumstat": + return success("3\t1\told-name.ts\tnew-name.ts\n"); + default: + return success(""); + } + }), + ), + ), + ), + ); + + it.effect("infers nul-delimited binary numstat renames when name-status is missing", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "history", + skip: 0, + limit: 10, + }); + + assert.deepStrictEqual(result.commits[0]?.files, [ + { + path: "new\tname.bin", + originalPath: "old\tname.bin", + status: "renamed", + insertions: 0, + deletions: 0, + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.branchCommitCount": + return success("1"); + case "vcs.panel.branchCommits": + return success( + "abc123\tabc123\tAda\tada@example.test\t2026-06-20T12:00:00.000Z\tRename binary", + ); + case "vcs.panel.commitRefs": + case "vcs.panel.commitNameStatus": + return success(""); + case "vcs.panel.commitNumstat": + return success("-\t-\t\0old\tname.bin\0new\tname.bin\0"); + default: + return success(""); + } + }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelService.Refresh.test.ts b/apps/server/src/sourceControl/SourceControlPanelService.Refresh.test.ts new file mode 100644 index 000000000000..356dc70661a2 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.Refresh.test.ts @@ -0,0 +1,643 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + GitCommandError, + SourceControlProviderError, + type ChangeRequest, + type SourceControlProviderKind, + type VcsRef, + type VcsStatusLocalResult, +} from "@t3tools/contracts"; + +import { + SourceControlPanelService, + layer as SourceControlPanelServiceLayer, +} from "./SourceControlPanelService.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { GitManager } from "../git/GitManager.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { GitVcsDriver, type ExecuteGitInput, type ExecuteGitResult } from "../vcs/GitVcsDriver.ts"; + +const branchRef: VcsRef = { + name: "feature/source-control", + current: false, + isDefault: false, + worktreePath: null, +}; +const isGitCommandError = Schema.is(GitCommandError); + +const success = (stdout = ""): ExecuteGitResult => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const failure = (stderr: string): ExecuteGitResult => ({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr, + stdoutTruncated: false, + stderrTruncated: false, +}); + +const emptyProvider = SourceControlProvider.SourceControlProvider.of({ + kind: "unknown", + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getChangeRequest", + cwd: "/repo", + detail: "get change request not stubbed", + }), + ), + createChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createChangeRequest", + cwd: "/repo", + detail: "create change request not stubbed", + }), + ), + getRepositoryCloneUrls: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getRepositoryCloneUrls", + cwd: "/repo", + detail: "repository clone URLs not stubbed", + }), + ), + getCommitAvatarUrl: () => Effect.succeed(null), + createRepository: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createRepository", + cwd: "/repo", + detail: "create repository not stubbed", + }), + ), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.checkoutChangeRequest", + cwd: "/repo", + detail: "checkout change request not stubbed", + }), + ), +}); + +function makeTestLayer( + execute: (input: ExecuteGitInput) => Effect.Effect, + workflow: Partial = {}, + providers: Partial< + Record + > = {}, + settings: Parameters[0] = {}, +) { + return SourceControlPanelServiceLayer.pipe( + Layer.provide(Layer.mock(GitManager)({ branchPullRequest: () => Effect.succeed(null) })), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest(settings)), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + }), + ), + Layer.provide( + Layer.succeed(GitWorkflowService, { + status: (input) => + workflow.status + ? workflow.status(input) + : workflow.localStatus + ? workflow.localStatus(input).pipe( + Effect.map((status) => ({ + ...status, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: + (status as { readonly aheadOfDefaultCount?: number }).aheadOfDefaultCount ?? + 0, + pr: null, + })), + ) + : Effect.fail( + new GitCommandError({ + operation: "test.status", + command: "git status", + cwd: "/repo", + detail: "status not stubbed", + }), + ), + localStatus: () => + Effect.fail( + new GitCommandError({ + operation: "test.localStatus", + command: "git status", + cwd: "/repo", + detail: "local status not stubbed", + }), + ), + pullCurrentBranch: () => + Effect.fail( + new GitCommandError({ + operation: "test.pullCurrentBranch", + command: "git pull", + cwd: "/repo", + detail: "pull not stubbed", + }), + ), + ...workflow, + } as GitWorkflowService["Service"]), + ), + Layer.provide( + Layer.succeed(GitVcsDriver, { + execute, + invalidateRefs: () => Effect.void, + } as unknown as GitVcsDriver["Service"]), + ), + Layer.provide( + Layer.succeed( + SourceControlProviderRegistry, + SourceControlProviderRegistry.of({ + resolveLink: () => Effect.die("unused resolveLink"), + get: (kind) => Effect.succeed(providers[kind] ?? emptyProvider), + resolveHandle: () => Effect.succeed({ provider: emptyProvider, context: null }), + resolve: () => Effect.succeed(emptyProvider), + discover: Effect.succeed([]), + }), + ), + ), + ); +} + +const localStatus: VcsStatusLocalResult = { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/source-control", + hasWorkingTreeChanges: true, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, +}; + +describe("SourceControlPanelService", () => { + it.effect("rejects option-like branch names before creating a branch", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .createBranchFromCommit({ + cwd: "/repo", + sha: "abc", + branchName: "-D", + }) + .pipe(Effect.flip); + + assert.equal(error.detail, 'Branch name cannot start with "-".'); + }).pipe( + Effect.provide( + makeTestLayer(() => + Effect.sync(() => { + throw new Error("git should not run for invalid branch names"); + }), + ), + ), + ), + ); + + it.effect("refreshes only working-tree slices when repository status is unchanged", () => { + const calls: ExecuteGitInput[] = []; + const includePullRequestOptions: Array = []; + let dirty = false; + let aheadCount = 0; + let refs = "initial refs"; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const initial = yield* service.snapshot({ cwd: "/repo", refresh: "full" }); + assert.deepStrictEqual( + initial.localBranches.map((branch) => branch.name), + ["main"], + ); + calls.length = 0; + dirty = true; + + const incremental = yield* service.snapshot({ cwd: "/repo", refresh: "working-tree" }); + + assert.deepStrictEqual(incremental.localBranches, initial.localBranches); + assert.deepStrictEqual( + incremental.changeGroups.flatMap((group) => group.files.map((file) => file.path)), + ["changed.txt"], + ); + assert.deepStrictEqual(calls.map((call) => call.operation).toSorted(), [ + "vcs.panel.refsFingerprint", + "vcs.panel.stagedNameStatus", + "vcs.panel.stagedNumstat", + "vcs.panel.statusPorcelain", + "vcs.panel.unstagedNumstat", + "vcs.panel.workingTreePrefix", + ]); + + calls.length = 0; + yield* service.enrichWorkingTreeFiles({ cwd: "/repo", paths: ["changed.txt"] }); + assert.deepStrictEqual(calls, [], "enrichment should reuse the snapshot's Git scans"); + + refs = "rewritten refs with the same commit date"; + const rewritten = yield* service.snapshot({ cwd: "/repo", refresh: "working-tree" }); + assert.notEqual(rewritten.refsFingerprint, initial.refsFingerprint); + assert.isTrue(calls.some((call) => call.operation === "vcs.panel.localBranches")); + calls.length = 0; + aheadCount = 1; + const fallback = yield* service.snapshot({ cwd: "/repo", refresh: "working-tree" }); + assert.equal(fallback.status.aheadCount, 1); + assert.isTrue(calls.some((call) => call.operation === "vcs.panel.localBranches")); + assert.isTrue(includePullRequestOptions.length > 0); + assert.isTrue(includePullRequestOptions.every((include) => include === false)); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.refsFingerprint": + return success(refs); + case "vcs.panel.localBranches": + return success("main\t*\t/repo\t2026-07-20T10:00:00.000Z\torigin/main\t"); + case "vcs.panel.statusPorcelain": + return success( + dirty + ? [ + "# branch.oid abc", + "# branch.head main", + "# branch.upstream origin/main", + `# branch.ab +${aheadCount} -0`, + "1 .M N... 100644 100644 100644 222222 333333 changed.txt", + ].join("\n") + : [ + "# branch.oid abc", + "# branch.head main", + "# branch.upstream origin/main", + `# branch.ab +${aheadCount} -0`, + ].join("\n"), + ); + case "vcs.panel.unstagedNumstat": + return success(dirty ? "1\t0\tchanged.txt\0" : ""); + case "vcs.panel.worktrees": + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.stagedNameStatus": + return success(""); + default: + return success(""); + } + }), + { + status: (_input, options) => + Effect.sync(() => { + includePullRequestOptions.push(options?.includePullRequest); + return { + ...localStatus, + refName: "main", + isDefaultRef: true, + hasWorkingTreeChanges: dirty, + hasUpstream: true, + aheadCount, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }; + }), + }, + ), + ), + ); + }); + + it.effect("promotes working-tree refreshes while a full snapshot is in flight", () => { + let fullSnapshotStarted: Deferred.Deferred | null = null; + let releaseFullSnapshot: Deferred.Deferred | null = null; + let localBranchesCalls = 0; + let fresh = false; + return Effect.gen(function* () { + fullSnapshotStarted = yield* Deferred.make(); + releaseFullSnapshot = yield* Deferred.make(); + const service = yield* SourceControlPanelService; + + const initial = yield* service.snapshot({ cwd: "/repo", refresh: "full" }); + assert.equal(initial.stashes[0]?.message, "old stash"); + fresh = true; + + const fullSnapshotFiber = yield* Effect.forkChild( + service.snapshot({ cwd: "/repo", refresh: "full" }), + ); + yield* Deferred.await(fullSnapshotStarted); + + const watcherSnapshot = yield* service.snapshot({ + cwd: "/repo", + refresh: "working-tree", + }); + assert.equal(watcherSnapshot.stashes[0]?.message, "fresh stash"); + assert.equal(localBranchesCalls, 3); + + yield* Deferred.succeed(releaseFullSnapshot, undefined); + yield* Fiber.join(fullSnapshotFiber); + + const cachedSnapshot = yield* service.snapshot({ + cwd: "/repo", + refresh: "working-tree", + }); + assert.equal(cachedSnapshot.stashes[0]?.message, "fresh stash"); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.gen(function* () { + switch (input.operation) { + case "vcs.panel.localBranches": { + localBranchesCalls += 1; + if (localBranchesCalls === 2) { + if (!fullSnapshotStarted || !releaseFullSnapshot) { + throw new Error("Expected snapshot gates to be initialized"); + } + yield* Deferred.succeed(fullSnapshotStarted, undefined); + yield* Deferred.await(releaseFullSnapshot); + } + return success("main\t*\t/repo\t2026-07-20T10:00:00.000Z\torigin/main\t"); + } + case "vcs.panel.statusPorcelain": + return success( + [ + "# branch.oid abc", + "# branch.head main", + "# branch.upstream origin/main", + "# branch.ab +0 -0", + ].join("\n"), + ); + case "vcs.panel.stashes": + return success( + `stash@{0}\tabc123\t2026-07-20T10:00:00.000Z\t${ + fresh ? "fresh stash" : "old stash" + }`, + ); + default: + return success(""); + } + }), + { + status: () => + Effect.succeed({ + ...localStatus, + refName: "main", + isDefaultRef: true, + hasWorkingTreeChanges: false, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }, + ), + ), + ); + }); + + it.effect("keeps snapshot generations unique after cwd cache eviction", () => { + let firstSnapshotStarted: Deferred.Deferred | null = null; + let releaseFirstSnapshot: Deferred.Deferred | null = null; + let rootLocalBranchesCalls = 0; + return Effect.gen(function* () { + firstSnapshotStarted = yield* Deferred.make(); + releaseFirstSnapshot = yield* Deferred.make(); + const service = yield* SourceControlPanelService; + + const olderSnapshotFiber = yield* Effect.forkChild( + service.snapshot({ cwd: "/repo", refresh: "full" }), + ); + yield* Deferred.await(firstSnapshotStarted); + + for (let index = 0; index < 64; index += 1) { + yield* service.snapshot({ cwd: `/repo-${index}`, refresh: "full" }); + } + + const newerSnapshot = yield* service.snapshot({ cwd: "/repo", refresh: "full" }); + assert.equal(newerSnapshot.localBranches[0]?.name, "newer"); + + yield* Deferred.succeed(releaseFirstSnapshot, undefined); + yield* Fiber.join(olderSnapshotFiber); + + const cachedSnapshot = yield* service.snapshot({ + cwd: "/repo", + refresh: "working-tree", + }); + assert.equal(cachedSnapshot.localBranches[0]?.name, "newer"); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.gen(function* () { + switch (input.operation) { + case "vcs.panel.localBranches": { + if (input.cwd !== "/repo") { + return success(`main\t*\t${input.cwd}\t2026-07-20T10:00:00.000Z\t\t`); + } + rootLocalBranchesCalls += 1; + if (rootLocalBranchesCalls === 1) { + if (!firstSnapshotStarted || !releaseFirstSnapshot) { + throw new Error("Expected snapshot gates to be initialized"); + } + yield* Deferred.succeed(firstSnapshotStarted, undefined); + yield* Deferred.await(releaseFirstSnapshot); + return success("older\t*\t/repo\t2026-07-20T10:00:00.000Z\t\t"); + } + return success("newer\t*\t/repo\t2026-07-20T10:00:00.000Z\t\t"); + } + case "vcs.panel.statusPorcelain": + return success(["# branch.oid abc", "# branch.head main"].join("\n")); + default: + return success(""); + } + }), + { + status: () => + Effect.succeed({ + ...localStatus, + refName: "main", + isDefaultRef: true, + hasWorkingTreeChanges: false, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }, + ), + ), + ); + }); + + it.effect("allows incremental refreshes after a full snapshot fails", () => { + let localBranchesCalls = 0; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.snapshot({ cwd: "/repo", refresh: "full" }); + + const failedFullExit = yield* Effect.exit( + service.snapshot({ cwd: "/repo", refresh: "full" }), + ); + assert.isTrue(Exit.isFailure(failedFullExit)); + + yield* service.snapshot({ cwd: "/repo", refresh: "working-tree" }); + assert.equal(localBranchesCalls, 2); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + if (input.operation === "vcs.panel.localBranches") { + localBranchesCalls += 1; + if (localBranchesCalls === 2) { + return failure("failed full snapshot"); + } + return success("main\t*\t/repo\t2026-07-20T10:00:00.000Z\torigin/main\t"); + } + if (input.operation === "vcs.panel.statusPorcelain") { + return success( + [ + "# branch.oid abc", + "# branch.head main", + "# branch.upstream origin/main", + "# branch.ab +0 -0", + ].join("\n"), + ); + } + return success(""); + }), + { + status: () => + Effect.succeed({ + ...localStatus, + refName: "main", + isDefaultRef: true, + hasWorkingTreeChanges: false, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }, + ), + ), + ); + }); + + it.effect("disables fetch maintenance and preserves refresh behavior", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + assert.equal(yield* service.fetchAllRemotes({ cwd: "/repo" }), true); + assert.equal(yield* service.fetchAllRemotes({ cwd: "/repo-linked" }), false); + assert.equal(yield* service.fetchAllRemotes({ cwd: "/repo", force: true }), true); + assert.equal(yield* service.fetchAllRemotes({ cwd: "/repo-linked" }), false); + + const fetchCalls = calls.filter((call) => call.operation === "vcs.panel.fetchAllRemotes"); + assert.equal(fetchCalls.length, 2); + for (const call of fetchCalls) { + assert.deepStrictEqual(call.env, { + GCM_INTERACTIVE: "never", + GIT_ASKPASS: "", + GIT_TERMINAL_PROMPT: "0", + SSH_ASKPASS: "", + SSH_ASKPASS_REQUIRE: "never", + }); + assert.deepStrictEqual(call.args, [ + "--git-dir", + "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/repo/.git", + "fetch", + "--all", + "--no-auto-gc", + ]); + } + assert.equal(fetchCalls[0]?.cwd, "/repo"); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.resolveGitCommonDir" + ? success("/repo/.git\n") + : success(); + }), + ), + ), + ); + }); + + it.effect("disables automatic fetch-all while preserving a forced fetch", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + assert.equal(yield* service.fetchAllRemotes({ cwd: "/repo" }), false); + assert.equal(yield* service.fetchAllRemotes({ cwd: "/repo", force: true }), true); + + const fetchCalls = calls.filter((call) => call.operation === "vcs.panel.fetchAllRemotes"); + assert.equal(fetchCalls.length, 1); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.resolveGitCommonDir" + ? success("/repo/.git\n") + : success(); + }), + {}, + {}, + { + backgroundActivity: { + schemaVersion: 1, + profile: "custom", + baseProfile: "balanced", + overrides: { + sourceControlAllRemotesFetchInterval: Duration.zero, + }, + }, + }, + ), + ), + ); + }); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelService.Snapshot.test.ts b/apps/server/src/sourceControl/SourceControlPanelService.Snapshot.test.ts new file mode 100644 index 000000000000..4ca28a8f12cc --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.Snapshot.test.ts @@ -0,0 +1,933 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { + GitCommandError, + GitManagerError, + SourceControlProviderError, + type SourceControlProviderKind, + type VcsRef, + type VcsStatusLocalResult, +} from "@t3tools/contracts"; + +import { + SourceControlPanelService, + layer as SourceControlPanelServiceLayer, +} from "./SourceControlPanelService.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { GitManager } from "../git/GitManager.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { GitVcsDriver, type ExecuteGitInput, type ExecuteGitResult } from "../vcs/GitVcsDriver.ts"; + +const branchRef: VcsRef = { + name: "feature/source-control", + current: false, + isDefault: false, + worktreePath: null, +}; +const isGitCommandError = Schema.is(GitCommandError); + +const success = (stdout = ""): ExecuteGitResult => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const failure = (stderr: string): ExecuteGitResult => ({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr, + stdoutTruncated: false, + stderrTruncated: false, +}); + +const emptyProvider = SourceControlProvider.SourceControlProvider.of({ + kind: "unknown", + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getChangeRequest", + cwd: "/repo", + detail: "get change request not stubbed", + }), + ), + createChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createChangeRequest", + cwd: "/repo", + detail: "create change request not stubbed", + }), + ), + getRepositoryCloneUrls: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getRepositoryCloneUrls", + cwd: "/repo", + detail: "repository clone URLs not stubbed", + }), + ), + getCommitAvatarUrl: () => Effect.succeed(null), + createRepository: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createRepository", + cwd: "/repo", + detail: "create repository not stubbed", + }), + ), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.checkoutChangeRequest", + cwd: "/repo", + detail: "checkout change request not stubbed", + }), + ), +}); + +function makeTestLayer( + execute: (input: ExecuteGitInput) => Effect.Effect, + workflow: Partial = {}, + providers: Partial< + Record + > = {}, + settings: Parameters[0] = {}, + branchPullRequest: GitManager["Service"]["branchPullRequest"] = () => Effect.succeed(null), +) { + return SourceControlPanelServiceLayer.pipe( + Layer.provide(Layer.mock(GitManager)({ branchPullRequest })), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest(settings)), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + }), + ), + Layer.provide( + Layer.succeed(GitWorkflowService, { + status: (input) => + workflow.status + ? workflow.status(input) + : workflow.localStatus + ? workflow.localStatus(input).pipe( + Effect.map((status) => ({ + ...status, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: + (status as { readonly aheadOfDefaultCount?: number }).aheadOfDefaultCount ?? + 0, + pr: null, + })), + ) + : Effect.fail( + new GitCommandError({ + operation: "test.status", + command: "git status", + cwd: "/repo", + detail: "status not stubbed", + }), + ), + localStatus: () => + Effect.fail( + new GitCommandError({ + operation: "test.localStatus", + command: "git status", + cwd: "/repo", + detail: "local status not stubbed", + }), + ), + pullCurrentBranch: () => + Effect.fail( + new GitCommandError({ + operation: "test.pullCurrentBranch", + command: "git pull", + cwd: "/repo", + detail: "pull not stubbed", + }), + ), + ...workflow, + } as GitWorkflowService["Service"]), + ), + Layer.provide( + Layer.succeed(GitVcsDriver, { + execute, + invalidateRefs: () => Effect.void, + } as unknown as GitVcsDriver["Service"]), + ), + Layer.provide( + Layer.succeed( + SourceControlProviderRegistry, + SourceControlProviderRegistry.of({ + resolveLink: () => Effect.die("unused resolveLink"), + get: (kind) => Effect.succeed(providers[kind] ?? emptyProvider), + resolveHandle: () => Effect.succeed({ provider: emptyProvider, context: null }), + resolve: () => Effect.succeed(emptyProvider), + discover: Effect.succeed([]), + }), + ), + ), + ); +} + +const localStatus: VcsStatusLocalResult = { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/source-control", + hasWorkingTreeChanges: true, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, +}; + +describe("SourceControlPanelService", () => { + it.effect("reports a branch whose configured upstream is gone as unpublished", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + + assert.deepStrictEqual( + snapshot.localBranches.map((branch) => ({ + name: branch.name, + upstreamName: branch.upstreamName, + upstreamRemoteName: branch.upstreamRemoteName, + })), + [ + { name: "feature/source-control", upstreamName: null, upstreamRemoteName: undefined }, + { name: "main", upstreamName: "origin/main", upstreamRemoteName: "origin" }, + ], + ); + assert.strictEqual(snapshot.status.hasUpstream, false); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + [ + "main\t\t\t2026-06-20T12:00:00.000Z\torigin/main\t\torigin", + "feature/source-control\t*\t\t2026-06-21T12:00:00.000Z\torigin/feature/source-control\t[gone]\torigin", + ].join("\n"), + ); + case "vcs.panel.statusPorcelain": + return success( + [ + "# branch.oid abc", + "# branch.head feature/source-control", + "# branch.upstream origin/feature/source-control", + ].join("\n"), + ); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ), + ); + + it.effect("attaches worktree paths from git worktree porcelain output", () => { + const worktreePath = process.cwd(); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: worktreePath }); + + assert.deepStrictEqual( + snapshot.localBranches.map((branch) => ({ + name: branch.name, + current: branch.current, + worktreePath: branch.worktreePath, + })), + [ + { + name: "feature/source-control", + current: true, + worktreePath, + }, + { + name: "main", + current: false, + worktreePath, + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + [ + "main\t\t2026-06-20T12:00:00.000Z\torigin/main\t", + "feature/source-control\t*\t2026-06-21T12:00:00.000Z\torigin/feature/source-control\t[ahead 1]", + ].join("\n"), + ); + case "vcs.panel.worktrees": + return success( + [ + `worktree ${worktreePath}`, + "HEAD abc", + "branch refs/heads/main", + "", + `worktree ${worktreePath}`, + "HEAD def", + "branch refs/heads/feature/source-control", + "", + ].join("\n"), + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head feature/source-control"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ); + }); + + it.effect("drops missing branch worktree paths before building the panel snapshot", () => { + const missingWorktreePath = `${process.cwd()}/.missing-source-control-worktree-test`; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const service = yield* SourceControlPanelService; + + assert.isFalse(yield* fileSystem.exists(missingWorktreePath)); + + const snapshot = yield* service.snapshot({ cwd: process.cwd() }); + + assert.deepStrictEqual( + snapshot.localBranches.map((branch) => ({ + name: branch.name, + current: branch.current, + worktreePath: branch.worktreePath, + })), + [ + { + name: "feature/source-control", + current: true, + worktreePath: null, + }, + { + name: "main", + current: false, + worktreePath: null, + }, + ], + ); + assert.deepStrictEqual(snapshot.worktreeChangeSets, []); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + [ + "main\t\t2026-06-20T12:00:00.000Z\torigin/main\t", + "feature/source-control\t*\t2026-06-21T12:00:00.000Z\torigin/feature/source-control\t[ahead 1]", + ].join("\n"), + ); + case "vcs.panel.worktrees": + return success( + [ + `worktree ${missingWorktreePath}`, + "HEAD abc", + "branch refs/heads/main", + "", + `worktree ${missingWorktreePath}`, + "HEAD def", + "branch refs/heads/feature/source-control", + "", + ].join("\n"), + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head feature/source-control"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ); + }); + + it.effect("includes dirty non-current worktrees as separate change sets", () => { + const rootPath = process.cwd(); + const worktreePath = `${process.cwd()}/..`; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: rootPath }); + + assert.deepStrictEqual( + snapshot.worktreeChangeSets.map((changeSet) => ({ + branchName: changeSet.branchName, + worktreePath: changeSet.worktreePath, + files: changeSet.changeGroups.flatMap((group) => + group.files.map((file) => ({ + group: group.kind, + path: file.path, + status: file.status, + insertions: file.insertions, + deletions: file.deletions, + })), + ), + })), + [ + { + branchName: "feature/source-control", + worktreePath, + files: [ + { + group: "staged", + path: "src/staged.ts", + status: "added", + insertions: 2, + deletions: 0, + }, + { + group: "unstaged", + path: "src/unstaged.ts", + status: "modified", + insertions: 3, + deletions: 1, + }, + ], + }, + ], + ); + assert.equal(snapshot.changeGroups.flatMap((group) => group.files).length, 0); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + [ + `main\t*\t${rootPath}\t2026-06-20T12:00:00.000Z\torigin/main\t`, + `feature/source-control\t\t${worktreePath}\t2026-06-21T12:00:00.000Z\torigin/feature/source-control\t`, + ].join("\n"), + ); + case "vcs.panel.worktrees": + return success( + [ + `worktree ${rootPath}`, + "HEAD abc", + "branch refs/heads/main", + "", + `worktree ${worktreePath}`, + "HEAD def", + "branch refs/heads/feature/source-control", + "", + ].join("\n"), + ); + case "vcs.panel.statusPorcelain": + if (input.cwd === worktreePath) { + return success( + [ + "# branch.oid def", + "# branch.head feature/source-control", + "1 A. N... 000000 100644 100644 000000 111111 src/staged.ts", + "1 .M N... 100644 100644 100644 222222 333333 src/unstaged.ts", + ].join("\n"), + ); + } + return success("# branch.oid abc\n# branch.head main"); + case "vcs.panel.stagedNumstat": + return input.cwd === worktreePath + ? success("2\t0\tsrc/staged.ts\0") + : success(""); + case "vcs.panel.stagedNameStatus": + return input.cwd === worktreePath ? success("A\0src/staged.ts\0") : success(""); + case "vcs.panel.unstagedNumstat": + return input.cwd === worktreePath + ? success("3\t1\tsrc/unstaged.ts\0") + : success(""); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "main", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ); + }); + + it.effect("falls back to branch-format worktree paths when worktree porcelain is empty", () => { + const rootPath = process.cwd(); + const worktreePath = process.cwd(); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: worktreePath }); + + assert.deepStrictEqual( + snapshot.localBranches.map((branch) => ({ + name: branch.name, + current: branch.current, + worktreePath: branch.worktreePath, + })), + [ + { + name: "feature/source-control", + current: true, + worktreePath, + }, + { + name: "main", + current: false, + worktreePath: rootPath, + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + assert.ok(input.args.join(" ").includes("%(worktreepath)")); + return success( + [ + `main\t\t${rootPath}\t2026-06-20T12:00:00.000Z\torigin/main\t`, + `feature/source-control\t*\t${worktreePath}\t2026-06-21T12:00:00.000Z\torigin/feature/source-control\t[ahead 1]`, + ].join("\n"), + ); + case "vcs.panel.worktrees": + return success(""); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head feature/source-control"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ); + }); + + it.effect("falls back when git branch does not support worktreepath formatting", () => { + let localBranchesCalls = 0; + + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + + assert.equal(localBranchesCalls, 2); + assert.deepStrictEqual( + snapshot.localBranches.map((branch) => ({ + name: branch.name, + current: branch.current, + worktreePath: branch.worktreePath, + lastActivityAt: branch.lastActivityAt, + upstreamName: branch.upstreamName, + upstreamRemoteName: branch.upstreamRemoteName, + aheadCount: branch.aheadCount, + behindCount: branch.behindCount, + })), + [ + { + name: "feature/source-control", + current: true, + worktreePath: null, + lastActivityAt: "2026-06-21T12:00:00.000Z", + upstreamName: "origin/feature/source-control", + upstreamRemoteName: "origin", + aheadCount: 1, + behindCount: 0, + }, + { + name: "main", + current: false, + worktreePath: null, + lastActivityAt: "2026-06-20T12:00:00.000Z", + upstreamName: "origin/main", + upstreamRemoteName: ".", + aheadCount: 0, + behindCount: 0, + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + localBranchesCalls += 1; + assert.ok(input.args.join(" ").endsWith("%09%(upstream:remotename)")); + if (localBranchesCalls === 1) { + assert.ok(input.args.join(" ").includes("%(worktreepath)")); + assert.equal(input.allowNonZeroExit, true); + return failure("fatal: unknown field name: worktreepath"); + } + assert.ok(!input.args.join(" ").includes("%(worktreepath)")); + assert.ok(input.args.join(" ").includes("%09%09")); + return success( + [ + "main\t\t\t2026-06-20T12:00:00.000Z\torigin/main\t\t.", + "feature/source-control\t*\t\t2026-06-21T12:00:00.000Z\torigin/feature/source-control\t[ahead 1]\torigin", + ].join("\n"), + ); + case "vcs.panel.worktrees": + return success(""); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head feature/source-control"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ); + }); + + for (const upstreamRemoteName of [".", "origin"]) { + it.effect(`preserves fork candidates for upstream identity ${upstreamRemoteName}`, () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + assert.equal(snapshot.localBranches[0]?.upstreamRemoteName, upstreamRemoteName); + assert.deepStrictEqual( + snapshot.actionableForkBranches.map((fork) => fork.remoteName), + upstreamRemoteName === "." ? ["origin"] : [], + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + assert.ok(input.args.join(" ").endsWith("%09%(upstream:remotename)")); + return success( + `main\t*\t/repo\t2026-06-20T12:00:00Z\torigin/main\t\t${upstreamRemoteName}`, + ); + case "vcs.panel.remotes": + return success( + "origin\tgit@example.test:one/repo.git\t(fetch)\nother\tgit@example.test:two/repo.git\t(fetch)", + ); + case "vcs.panel.remoteBranches": + return success( + input.args.includes("origin/*") ? "origin/main\t2026-06-20T12:00:00Z" : "", + ); + case "vcs.panel.branchForkMergeBase": + return success("abc\n"); + case "vcs.panel.branchForkAheadBehind": + return success("0\t2\n"); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head main"); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ ...localStatus, refName: "main", hasWorkingTreeChanges: false }), + }, + ), + ), + ), + ); + } + + it.effect("keeps git-derived actionable forks when branch pull request lookup fails", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + + assert.deepStrictEqual(snapshot.actionableForkBranches, [ + { + localBranchName: "feature", + remoteName: "upstream", + remoteBranchName: "feature", + remoteRefName: "upstream/feature", + aheadCount: 2, + behindCount: 3, + lastActivityAt: "2026-06-17T09:00:00.000Z", + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + "feature\t*\t/repo\t2026-06-17T10:00:00.000Z\torigin/feature\t[ahead 1]\torigin", + ); + case "vcs.panel.remotes": + return success( + [ + "origin\tgit@github.com:fork/repo.git\t(fetch)", + "origin\tgit@github.com:fork/repo.git\t(push)", + "upstream\tgit@github.com:upstream/repo.git\t(fetch)", + "upstream\tgit@github.com:upstream/repo.git\t(push)", + ].join("\n"), + ); + case "vcs.panel.remoteBranches": + return input.args.includes("origin/*") + ? success("origin/feature\t2026-06-17T08:00:00.000Z\n") + : success("upstream/feature\t2026-06-17T09:00:00.000Z\n"); + case "vcs.panel.branchForkMergeBase": + return success("abc123\n"); + case "vcs.panel.branchForkAheadBehind": + return success("2\t3\n"); + case "vcs.panel.statusPorcelain": + return success(["# branch.oid abc", "# branch.head feature"].join("\n")); + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + case "vcs.panel.stashes": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "feature", + hasWorkingTreeChanges: false, + }), + }, + {}, + {}, + () => + Effect.fail( + new GitManagerError({ + operation: "branchPullRequest", + cwd: "/repo", + detail: "provider unavailable", + }), + ), + ), + ), + ), + ); + + it.effect("discovers PR targets for every local branch using the shared branch lookup", () => { + const lookupInputs: Array<{ cwd: string; branch: string }> = []; + const names = Array.from({ length: 105 }, (_, i) => `feature-${i}`); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + + // All branches are synced with origin. The old repository-wide page would + // omit older PRs; target discovery must not depend on that page's contents. + assert.equal(lookupInputs.length, names.length); + assert.deepStrictEqual(lookupInputs.map((input) => input.branch).sort(), names.toSorted()); + assert.deepStrictEqual( + snapshot.actionableForkBranches + .toSorted((left, right) => left.localBranchName.localeCompare(right.localBranchName)) + .map((fork) => ({ + branch: fork.localBranchName, + target: fork.remoteRefName, + ahead: fork.aheadCount, + behind: fork.behindCount, + prNumber: fork.pr?.number, + prUrl: fork.pr?.url, + })), + [ + { + branch: "feature-103", + target: "upstream/feature-103", + ahead: 2, + behind: 17, + prNumber: 42, + prUrl: "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/acme/repo/pull/42", + }, + { + branch: "feature-104", + target: "upstream/main", + ahead: 2, + behind: 17, + prNumber: 42, + prUrl: "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/acme/repo/pull/42", + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + names + .map((name) => `${name}\t\t\t2026-06-17T10:00:00.000Z\torigin/${name}\t`) + .join("\n"), + ); + case "vcs.panel.remotes": + return success( + "origin\tgit@github.com:fork/repo.git\t(fetch)\nupstream\thttps://github.com/acme/repo.git\t(fetch)", + ); + case "vcs.panel.remoteBranches": + return input.args.includes("origin/*") + ? success(["origin/main", ...names.map((name) => `origin/${name}`)].join("\n")) + : success( + "upstream/main\t2026-06-17T11:00:00.000Z\nupstream/feature-103\t2026-06-17T11:00:00.000Z", + ); + case "vcs.panel.branchForkMergeBase": + return success("abc123\n"); + case "vcs.panel.branchForkAheadBehind": + return input.args.includes("feature-103...upstream/feature-103") || + ["feature-2", "feature-3", "feature-104"].some((branch) => + input.args.includes(`${branch}...upstream/main`), + ) + ? success("2\t17\n") + : success("1\t0\n"); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head main"); + default: + return success(""); + } + }), + { localStatus: () => Effect.succeed({ ...localStatus, hasWorkingTreeChanges: false }) }, + { + github: SourceControlProvider.SourceControlProvider.of({ + ...emptyProvider, + kind: "github", + listChangeRequests: () => Effect.die("Panel must not scan repository PR pages"), + }), + }, + {}, + (input) => { + lookupInputs.push(input); + if (input.branch === "feature-0") + return Effect.fail( + new GitManagerError({ + operation: "branchPullRequest", + cwd: input.cwd, + detail: "provider unavailable", + }), + ); + if (input.branch === "feature-1") return Effect.succeed(null); + return Effect.succeed({ + number: 42, + title: "Feature", + url: "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/acme/repo/pull/42", + baseRef: input.branch === "feature-103" ? "feature-103" : "main", + headRef: input.branch, + isDraft: false, + state: input.branch === "feature-2" ? ("closed" as const) : ("open" as const), + repositoryKey: + input.branch === "feature-3" ? "github.com/other/repo" : "github.com/acme/repo", + updatedAt: null, + }); + }, + ), + ), + ); + }); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelService.ts b/apps/server/src/sourceControl/SourceControlPanelService.ts new file mode 100644 index 000000000000..58fca79116c4 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.ts @@ -0,0 +1,1101 @@ +import * as NodeCrypto from "node:crypto"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { + DEFAULT_SOURCE_CONTROL_ALL_REMOTES_FETCH_INTERVAL, + GitCommandError, + type VcsPanelAddRemoteInput, + type VcsPanelBranchActionInput, + type VcsPanelBranchCommitsInput, + type VcsPanelBranchCommitsResult, + type VcsPanelBranchDetails, + type VcsPanelBranchDetailsInput, + type VcsPanelCommitActionInput, + type VcsPanelCommitInput, + type VcsPanelCommitFilesInput, + type VcsPanelCommitFilesResult, + type VcsPanelCompareInput, + type VcsPanelCompareResult, + type VcsPanelDeleteBranchInput, + type VcsPanelFileActionInput, + type VcsPanelFileChange, + type VcsPanelFileDiffInput, + type VcsPanelFileDiffResult, + type VcsPanelFetchAllRemotesInput, + type VcsPanelRemote, + type VcsPanelRemoteInput, + type VcsPanelRefActionInput, + type VcsPanelSnapshotInput, + type VcsPanelSnapshotResult, + type VcsPanelStashDetails, + type VcsPanelStashDetailsInput, + type VcsPanelStashInput, + type VcsPanelUndoCommitInput, + type VcsPanelWorktreeChangeSet, + type VcsPanelWorkingTreeFileEnrichmentInput, + type VcsPanelWorkingTreeFileEnrichmentResult, + type SourceControlProviderKind, + type VcsPullResult, + type VcsRef, + type VcsStatusResult, +} from "@t3tools/contracts"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; + +import { sanitizeErrorCause } from "../diagnostics/ErrorCause.ts"; +import { canonicalizeExistingPath } from "../utils/CanonicalPath.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { + makeSourceControlRepositoryInstructionReader, + makeSourceControlWritingPolicyResolver, +} from "../textGeneration/SourceControlWriting.ts"; +import { GitVcsDriver, type ExecuteGitProgress } from "../vcs/GitVcsDriver.ts"; +import { STATUS_UPSTREAM_REFRESH_ENV } from "../vcs/GitVcsDriverCore.ts"; +import { resolveGitCommandTimeoutMs } from "../vcs/GitCommandTimeout.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { makeSourceControlPanelActions } from "./SourceControlPanelActions.ts"; +import { + parseCommits, + parseFileChangesFromNumstat, + parseLocalBranches, + parseNameStatus, + parseRemoteBranches, + parseRemoteVerbose, + parseStashes, + parseWorktreeBranchEntries, + parseWorktreeBranchPaths, + uniquePaths, + type WorktreeBranchEntry, +} from "./SourceControlPanelParsers.ts"; +import { GitManager } from "../git/GitManager.ts"; +import { makeSourceControlPanelReaders } from "./SourceControlPanelReaders.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; +import { + mergeNumstats, + panelStatusFromLocal, + parseNumstat, + fileChangesRelativeToCwd, + readNulField, + untrackedPathsFromPorcelain, + unstagedFilesFromPorcelainStatus, +} from "./SourceControlPanelStatusParsers.ts"; +const isGitCommandError = Schema.is(GitCommandError); +const LOCAL_BRANCHES_WITH_WORKTREE_PATH_ARGS = [ + "branch", + "--format=%(refname:lstrip=2)%09%(HEAD)%09%(worktreepath)%09%(committerdate:iso-strict)%09%(upstream:lstrip=2)%09%(upstream:track)%09%(upstream:remotename)", +] as const; +const LOCAL_BRANCHES_WITHOUT_WORKTREE_PATH_ARGS = [ + "branch", + "--format=%(refname:lstrip=2)%09%(HEAD)%09%09%(committerdate:iso-strict)%09%(upstream:lstrip=2)%09%(upstream:track)%09%(upstream:remotename)", +] as const; + +type ConfiguredSourceControlProviderKind = Exclude; + +function isConfiguredSourceControlProviderKind( + kind: SourceControlProviderKind, +): kind is ConfiguredSourceControlProviderKind { + return kind !== "unknown"; +} + +interface PanelSnapshotCacheState { + readonly nextRequestId: number; + readonly latestRequestByCwd: ReadonlyMap; + readonly latestFullRequestByCwd: ReadonlyMap; + readonly completedFullRequestByCwd: ReadonlyMap; + readonly snapshotsByCwd: ReadonlyMap; +} + +const PANEL_SNAPSHOT_CACHE_CAPACITY = 64; +const COMMIT_HOOK_NATIVE_DEPENDENCY_FAILURE_DETAIL = + "The Git pre-commit hook could not load a required native dependency. Reinstall the repository dependencies and try again."; +const COMMIT_HOOK_FAILURE_DETAIL = + "The Git pre-commit hook failed. Run the repository pre-commit hook in a terminal for details."; + +type CommitFailureHint = "hook-failed" | "native-dependency"; + +function commitFailureHintFromOutputLine(line: string): CommitFailureHint | null { + if ( + line.includes("Cannot find native binding") || + line.includes("Cannot find module 'vite-plus/binding'") || + line.includes('Cannot find module "vite-plus/binding"') + ) { + return "native-dependency"; + } + if (line.includes("VITE+ - pre-commit script failed")) { + return "hook-failed"; + } + return null; +} + +function commitFailureDetail(hint: CommitFailureHint | null): string | null { + switch (hint) { + case "native-dependency": + return COMMIT_HOOK_NATIVE_DEPENDENCY_FAILURE_DETAIL; + case "hook-failed": + return COMMIT_HOOK_FAILURE_DETAIL; + case null: + return null; + } +} + +function setBoundedMapEntry( + source: ReadonlyMap, + key: K, + value: V, + capacity: number, +): ReadonlyMap { + const next = new Map(source); + next.delete(key); + next.set(key, value); + while (next.size > capacity) { + const oldestKey = next.keys().next().value; + if (oldestKey === undefined) break; + next.delete(oldestKey); + } + return next; +} + +export class SourceControlPanelService extends Context.Service< + SourceControlPanelService, + { + readonly snapshot: ( + input: VcsPanelSnapshotInput, + ) => Effect.Effect; + readonly branchDetails: ( + input: VcsPanelBranchDetailsInput, + ) => Effect.Effect; + readonly commitFiles: ( + input: VcsPanelCommitFilesInput, + ) => Effect.Effect; + readonly branchCommits: ( + input: VcsPanelBranchCommitsInput, + ) => Effect.Effect; + readonly stashDetails: ( + input: VcsPanelStashDetailsInput, + ) => Effect.Effect; + readonly stageFiles: (input: VcsPanelFileActionInput) => Effect.Effect; + readonly unstageFiles: (input: VcsPanelFileActionInput) => Effect.Effect; + readonly discardFiles: (input: VcsPanelFileActionInput) => Effect.Effect; + readonly enrichWorkingTreeFiles: ( + input: VcsPanelWorkingTreeFileEnrichmentInput, + ) => Effect.Effect; + readonly readFileDiff: ( + input: VcsPanelFileDiffInput, + ) => Effect.Effect; + readonly commitStaged: (input: VcsPanelCommitInput) => Effect.Effect; + readonly pullBranch: ( + input: VcsPanelBranchActionInput, + ) => Effect.Effect; + readonly pushBranch: (input: VcsPanelBranchActionInput) => Effect.Effect; + readonly deleteBranch: ( + input: VcsPanelDeleteBranchInput, + ) => Effect.Effect; + readonly undoLatestCommit: ( + input: VcsPanelUndoCommitInput, + ) => Effect.Effect; + readonly revertCommit: ( + input: VcsPanelCommitActionInput, + ) => Effect.Effect; + readonly checkoutCommit: ( + input: VcsPanelCommitActionInput, + ) => Effect.Effect<{ readonly refName: string }, GitCommandError>; + readonly createBranchFromCommit: ( + input: VcsPanelCommitActionInput, + ) => Effect.Effect<{ readonly refName: string }, GitCommandError>; + readonly mergeBranchIntoCurrent: ( + input: VcsPanelRefActionInput, + ) => Effect.Effect; + readonly rebaseCurrentOnto: ( + input: VcsPanelRefActionInput, + ) => Effect.Effect; + readonly fetchBranch: ( + input: VcsPanelBranchActionInput, + ) => Effect.Effect; + readonly fetchRemote: (input: VcsPanelRemoteInput) => Effect.Effect; + readonly fetchAllRemotes: ( + input: VcsPanelFetchAllRemotesInput, + ) => Effect.Effect; + readonly addRemote: (input: VcsPanelAddRemoteInput) => Effect.Effect; + readonly removeRemote: (input: VcsPanelRemoteInput) => Effect.Effect; + readonly createStash: (input: VcsPanelStashInput) => Effect.Effect; + readonly applyStash: (input: VcsPanelStashInput) => Effect.Effect; + readonly popStash: (input: VcsPanelStashInput) => Effect.Effect; + readonly dropStash: (input: VcsPanelStashInput) => Effect.Effect; + readonly compare: ( + input: VcsPanelCompareInput, + ) => Effect.Effect; + } +>()("t3/sourceControl/SourceControlPanelService") {} + +function commandLabel(args: readonly string[]): string { + return `git ${args.join(" ")}`; +} + +function gitError( + operation: string, + cwd: string, + args: readonly string[], + detail: string, + cause?: unknown, +) { + return new GitCommandError({ + operation, + command: commandLabel(args), + cwd, + detail, + ...(cause === undefined ? {} : { cause: sanitizeErrorCause(cause) }), + }); +} + +function asGitCommandError(operation: string, cwd: string, args: readonly string[]) { + return (cause: unknown) => + isGitCommandError(cause) + ? cause + : gitError(operation, cwd, args, "Source control operation failed.", cause); +} + +function isUnsupportedWorktreePathFormat(detail: string) { + detail = detail.toLowerCase(); + return detail.includes("worktreepath") && detail.includes("unknown field"); +} + +export const make = Effect.fn("makeSourceControlPanelService")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver; + const path = yield* Path.Path; + const workflow = yield* GitWorkflowService; + const gitManager = yield* GitManager; + const serverSettings = yield* ServerSettingsService; + const sourceControlProviders = Option.getOrUndefined( + yield* Effect.serviceOption(SourceControlProviderRegistry), + ); + const sourceControlRateLimits = Option.getOrUndefined( + yield* Effect.serviceOption(SourceControlRateLimit.SourceControlRateLimit), + ); + const textGeneration = Option.getOrUndefined(yield* Effect.serviceOption(TextGeneration)); + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const projectionQuery = Option.getOrUndefined( + yield* Effect.serviceOption(ProjectionSnapshotQuery), + ); + + const runResult = ( + operation: string, + cwd: string, + args: readonly string[], + options?: { + readonly allowNonZeroExit?: boolean; + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly progress?: ExecuteGitProgress; + }, + ) => + git + .execute({ + operation, + cwd, + args, + ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), + ...(options?.env !== undefined ? { env: options.env } : {}), + ...(options?.progress !== undefined ? { progress: options.progress } : {}), + allowNonZeroExit: options?.allowNonZeroExit ?? false, + timeoutMs: resolveGitCommandTimeoutMs(args), + maxOutputBytes: 8 * 1024 * 1024, + appendTruncationMarker: true, + }) + .pipe(Effect.mapError(asGitCommandError(operation, cwd, args))); + + const run = ( + operation: string, + cwd: string, + args: readonly string[], + options?: { + readonly allowNonZeroExit?: boolean; + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly progress?: ExecuteGitProgress; + }, + ) => + runResult(operation, cwd, args, options).pipe( + Effect.flatMap((result) => { + if (options?.allowNonZeroExit === true || result.exitCode === 0) { + return Effect.succeed(result.stdout); + } + return Effect.fail( + gitError(operation, cwd, args, result.stderr.trim() || result.stdout.trim()), + ); + }), + ); + + const snapshotCacheRef = yield* Ref.make({ + nextRequestId: 0, + latestRequestByCwd: new Map(), + latestFullRequestByCwd: new Map(), + completedFullRequestByCwd: new Map(), + snapshotsByCwd: new Map(), + }); + + const sourceControlAllRemotesFetchInterval = serverSettings.getSettings.pipe( + Effect.map( + (settings) => + resolveServerBackgroundActivitySettings(settings).sourceControlAllRemotesFetchInterval, + ), + Effect.orElseSucceed(() => DEFAULT_SOURCE_CONTROL_ALL_REMOTES_FETCH_INTERVAL), + ); + + const fetchAllRemotesCache = yield* Cache.makeWith( + (gitCommonDir: string) => { + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + return Effect.gen(function* () { + const interval = yield* sourceControlAllRemotesFetchInterval; + yield* run( + "vcs.panel.fetchAllRemotes", + fetchCwd, + [ + "--git-dir", + gitCommonDir, + "fetch", + "--all", + // Repeated panel refreshes must not restart failed repacks on every fetch. + "--no-auto-gc", + ], + { env: STATUS_UPSTREAM_REFRESH_ENV }, + ).pipe(Effect.ensuring(git.invalidateRefs(fetchCwd))); + return interval; + }); + }, + { + capacity: 128, + timeToLive: (exit) => (Exit.isSuccess(exit) ? exit.value : Duration.seconds(5)), + }, + ); + + yield* Stream.runForEach(serverSettings.streamChanges, () => + Cache.invalidateAll(fetchAllRemotesCache), + ).pipe(Effect.forkScoped); + + const resolveGitCommonDir = Effect.fn("SourceControlPanelService.resolveGitCommonDir")(function* ( + cwd: string, + ) { + const commonDir = (yield* run("vcs.panel.resolveGitCommonDir", cwd, [ + "rev-parse", + "--git-common-dir", + ])).trim(); + return path.isAbsolute(commonDir) ? commonDir : path.resolve(cwd, commonDir); + }); + + const stashLocks = new Map(); + const withStashMutation = (cwd: string, effect: Effect.Effect) => + Effect.gen(function* () { + const commonDir = yield* canonicalizeExistingPath( + fileSystem, + yield* resolveGitCommonDir(cwd), + ); + return yield* Effect.acquireUseRelease( + Effect.sync(() => { + let entry = stashLocks.get(commonDir); + if (!entry) { + entry = { lock: Semaphore.makeUnsafe(1), callers: 0 }; + stashLocks.set(commonDir, entry); + } + entry.callers++; + return entry; + }), + (entry) => entry.lock.withPermit(effect), + (entry) => + Effect.sync(() => { + if (--entry.callers === 0) stashLocks.delete(commonDir); + }), + ); + }); + + const fetchAllRemotes: SourceControlPanelService["Service"]["fetchAllRemotes"] = Effect.fn( + "fetchAllRemotes", + )(function* (input) { + if (input.force !== true) { + const interval = yield* sourceControlAllRemotesFetchInterval; + if (Duration.isZero(interval)) return false; + } + const gitCommonDir = yield* resolveGitCommonDir(input.cwd); + if (input.force === true) { + yield* Cache.invalidate(fetchAllRemotesCache, gitCommonDir); + } else if (Option.isSome(yield* Cache.getOption(fetchAllRemotesCache, gitCommonDir))) { + return false; + } + yield* Cache.get(fetchAllRemotesCache, gitCommonDir); + return true; + }); + + const withTemporaryIntentToAddIndex = ( + input: { + readonly cwd: string; + readonly paths: readonly string[]; + readonly operations: { + readonly gitIndexPath: string; + readonly tempIndexReadTree: string; + readonly tempIndexIntentToAdd: string; + }; + }, + body: (env: NodeJS.ProcessEnv) => Effect.Effect, + ) => + Effect.gen(function* () { + const gitIndexPath = (yield* run(input.operations.gitIndexPath, input.cwd, [ + "rev-parse", + "--git-path", + "index", + ])).trim(); + const sourceIndexPath = path.isAbsolute(gitIndexPath) + ? gitIndexPath + : path.resolve(input.cwd, gitIndexPath); + const tempDir = yield* fileSystem.makeTempDirectory({ prefix: "t3-vcs-index-" }); + return yield* Effect.gen(function* () { + const tempIndexPath = path.join(tempDir, "index"); + const env = { ...globalThis.process.env, GIT_INDEX_FILE: tempIndexPath }; + yield* fileSystem.copyFile(sourceIndexPath, tempIndexPath).pipe( + Effect.catch(() => + run(input.operations.tempIndexReadTree, input.cwd, ["read-tree", "HEAD"], { + env, + }).pipe( + Effect.asVoid, + Effect.catch(() => Effect.void), + ), + ), + ); + yield* run( + input.operations.tempIndexIntentToAdd, + input.cwd, + ["--literal-pathspecs", "add", "-N", "--pathspec-from-file=-", "--pathspec-file-nul"], + // A deleted row can compare against thousands of untracked rename candidates. + // Keep those literal paths off the platform-limited process command line. + { env, stdin: `${input.paths.join("\0")}\0` }, + ).pipe(Effect.asVoid); + return yield* body(env); + }).pipe( + Effect.ensuring( + fileSystem.remove(tempDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + }); + + const withTemporarySelectedIndex = ( + cwd: string, + paths: readonly string[], + body: (env: NodeJS.ProcessEnv) => Effect.Effect, + ) => + Effect.gen(function* () { + const tempDir = yield* fileSystem + .makeTempDirectory({ prefix: "t3-vcs-selected-index-" }) + .pipe( + Effect.mapError(asGitCommandError("vcs.panel.commitStaged.tempIndex", cwd, ["commit"])), + ); + return yield* Effect.gen(function* () { + const env = { + ...globalThis.process.env, + GIT_INDEX_FILE: path.join(tempDir, "index"), + }; + const headResult = yield* runResult( + "vcs.panel.commitStaged.tempIndexResolveHead", + cwd, + ["rev-parse", "--verify", "HEAD"], + { allowNonZeroExit: true, env }, + ); + yield* run( + "vcs.panel.commitStaged.tempIndexReadTree", + cwd, + headResult.exitCode === 0 ? ["read-tree", "HEAD"] : ["read-tree", "--empty"], + { env }, + ).pipe(Effect.asVoid); + // A staged deletion can still exist on disk (git rm --cached), often newly ignored. + // Preserve that intent instead of adding the file back from the working tree. + const deleted = new Set( + (yield* run("vcs.panel.commitStaged.selectedDeletions", cwd, [ + "diff", + "--cached", + "--name-only", + "--diff-filter=D", + "-z", + ])).split("\0"), + ); + const selectedDeletions = paths.filter((file) => deleted.has(file)); + const selectedFiles = paths.filter((file) => !deleted.has(file)); + if (selectedDeletions.length > 0) { + yield* run( + "vcs.panel.commitStaged.tempIndexRemoveSelected", + cwd, + ["update-index", "--force-remove", "-z", "--stdin"], + { env, stdin: `${selectedDeletions.join("\0")}\0` }, + ); + } + if (selectedFiles.length > 0) { + yield* run( + "vcs.panel.commitStaged.tempIndexAddSelected", + cwd, + ["--literal-pathspecs", "add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"], + { env, stdin: `${selectedFiles.join("\0")}\0` }, + ); + } + return yield* body(env); + }).pipe( + Effect.ensuring( + fileSystem.remove(tempDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + }); + + const branchWithExistingWorktreePath = (branch: VcsRef) => { + if (!branch.worktreePath) return Effect.succeed(branch); + return fileSystem.exists(branch.worktreePath).pipe( + Effect.map((exists) => (exists ? branch : { ...branch, worktreePath: null })), + Effect.orElseSucceed(() => ({ ...branch, worktreePath: null })), + ); + }; + + const resolveWritingPolicy = makeSourceControlWritingPolicyResolver({ + runGit: (cwd, args) => run("vcs.panel.writingStyleRecentCommitSubjects", cwd, args), + readRepositoryInstructions: makeSourceControlRepositoryInstructionReader(fileSystem, path), + }); + + const { + actionableForkBranches, + commitFiles, + branchCommits, + branchDetails, + changeGroupsHaveFiles, + generatedCommitMessage, + generatedStashMessage, + readWorkingTreeChangeGroups: readRawWorkingTreeChangeGroups, + refExists, + stashDetails, + upstreamForRef, + } = makeSourceControlPanelReaders({ + run, + branchPullRequest: gitManager.branchPullRequest, + serverSettings, + sourceControlProviders, + sourceControlRateLimits, + textGeneration, + getProviders: providerRegistry.getProviders, + resolveWritingPolicy, + ...(projectionQuery + ? { + projectIdForWorkspace: (cwd: string) => + projectionQuery + .getActiveProjectByWorkspaceRoot(path.resolve(cwd)) + .pipe(Effect.map((project) => (Option.isSome(project) ? project.value.id : null))), + } + : {}), + }); + const unstagedFilesWithUntrackedRenames = ( + cwd: string, + untrackedPaths: readonly string[], + prefix: string, + ) => + Effect.gen(function* () { + if (untrackedPaths.length === 0) return null; + + return yield* withTemporaryIntentToAddIndex( + { + cwd, + paths: untrackedPaths, + operations: { + gitIndexPath: "vcs.panel.gitIndexPath", + tempIndexReadTree: "vcs.panel.tempIndexReadTree", + tempIndexIntentToAdd: "vcs.panel.tempIndexIntentToAdd", + }, + }, + (env) => + Effect.gen(function* () { + const [nameStatus, numstat] = yield* Effect.all( + [ + run( + "vcs.panel.unstagedNameStatusWithUntracked", + cwd, + ["diff", "--no-relative", "--name-status", "-z", "--find-renames=20%"], + { env }, + ), + run( + "vcs.panel.unstagedNumstatWithUntracked", + cwd, + ["diff", "--no-relative", "--numstat", "-z", "--find-renames=20%"], + { env }, + ), + ], + { concurrency: "unbounded" }, + ); + return fileChangesRelativeToCwd( + parseFileChangesFromNumstat({ + numstat, + statuses: parseNameStatus(nameStatus), + }), + prefix, + ); + }), + ); + }).pipe(Effect.orElseSucceed(() => null)); + + // Commit objects are immutable. Share reads across pages, panels, and clients. + const commitFilesCache = yield* Cache.makeWith( + (key: string) => { + const [cwd, sha] = JSON.parse(key) as [string, string]; + return commitFiles(cwd, sha); + }, + { + capacity: 128, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(10) : Duration.zero), + }, + ); + + // Scrolling may request several batches before Git finishes one status scan. + const enrichmentStatusCache = yield* Cache.makeWith( + (cwd: string) => + Effect.all( + [ + run("vcs.panel.enrichWorkingTreeFiles.statusPorcelain", cwd, [ + "-c", + "status.relativePaths=true", + "status", + "--porcelain=2", + "--branch", + "-uall", + ]), + run("vcs.panel.enrichWorkingTreeFiles.unstagedNumstat", cwd, [ + "diff", + "--no-relative", + "--numstat", + "-z", + "--find-renames=20%", + ]), + run("vcs.panel.workingTreePrefix", cwd, ["rev-parse", "--show-prefix"]).pipe( + Effect.map((output) => output.replace(/\r?\n$/u, "")), + ), + ], + { concurrency: "unbounded" }, + ), + { + capacity: 64, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.seconds(1) : Duration.zero), + }, + ); + + const readWorkingTreeChangeGroups = (cwd: string) => + readRawWorkingTreeChangeGroups(cwd).pipe( + Effect.tap((result) => + Cache.set(enrichmentStatusCache, path.resolve(cwd), [ + result.porcelain, + result.unstagedNumstat, + result.prefix, + ] satisfies [string, string, string]), + ), + ); + + const enrichWorkingTreeFiles: SourceControlPanelService["Service"]["enrichWorkingTreeFiles"] = + Effect.fn("enrichWorkingTreeFiles")(function* (input) { + const requestedPaths = uniquePaths(input.paths); + const [porcelain, unstagedNumstat, prefix] = yield* Cache.get( + enrichmentStatusCache, + path.resolve(input.cwd), + ); + + const requestedPathSet = new Set(requestedPaths); + const untrackedPaths = untrackedPathsFromPorcelain(porcelain); + const untrackedPathSet = new Set(untrackedPaths); + const unstagedFiles = unstagedFilesFromPorcelainStatus({ + status: porcelain, + unstagedStats: parseNumstat(unstagedNumstat, prefix), + }); + const deletedPathSet = new Set( + unstagedFiles.filter((file) => file.status === "deleted").map((file) => file.path), + ); + const requestedUntrackedPaths = requestedPaths.filter((path) => untrackedPathSet.has(path)); + const requestedDeletedPaths = requestedPaths.filter((path) => deletedPathSet.has(path)); + const renameCandidateUntrackedPaths = + requestedDeletedPaths.length > 0 ? untrackedPaths : requestedUntrackedPaths; + + const [untrackedStats, renameCandidates] = yield* Effect.all( + [ + Effect.forEach( + requestedUntrackedPaths, + (path) => + run( + "vcs.panel.enrichWorkingTreeFiles.untrackedNumstat", + input.cwd, + ["diff", "--no-index", "--numstat", "-z", "--", "/dev/null", path], + { allowNonZeroExit: true }, + ).pipe( + Effect.map(parseNumstat), + Effect.orElseSucceed(() => new Map()), + ), + { concurrency: 4 }, + ).pipe(Effect.map((stats) => mergeNumstats(stats))), + unstagedFilesWithUntrackedRenames(input.cwd, renameCandidateUntrackedPaths, prefix), + ], + { concurrency: "unbounded" }, + ); + + const filesByPath = new Map(); + const hiddenPaths = new Set(); + for (const file of renameCandidates ?? []) { + if (file.status !== "renamed" || !file.originalPath) continue; + if (!requestedPathSet.has(file.path) && !requestedPathSet.has(file.originalPath)) continue; + filesByPath.set(file.path, file); + hiddenPaths.add(file.originalPath); + } + + for (const path of requestedUntrackedPaths) { + if (filesByPath.has(path)) continue; + const stats = untrackedStats.get(path); + filesByPath.set(path, { + path, + originalPath: null, + status: "untracked", + insertions: stats?.insertions ?? 0, + deletions: stats?.deletions ?? 0, + }); + } + for (const file of unstagedFiles) { + if (file.status !== "deleted") continue; + if ( + !requestedPathSet.has(file.path) || + hiddenPaths.has(file.path) || + filesByPath.has(file.path) + ) { + continue; + } + filesByPath.set(file.path, file); + } + + return { + files: [...filesByPath.values()].toSorted((left, right) => + left.path.localeCompare(right.path), + ), + hiddenPaths: [...hiddenPaths].toSorted((left, right) => left.localeCompare(right)), + }; + }); + + const readWorktreeChangeSets = Effect.fn("readWorktreeChangeSets")(function* ( + cwd: string, + localBranches: ReadonlyArray, + worktreeBranchEntries: ReadonlyArray | null, + ) { + return yield* Effect.forEach( + localBranches.filter((branch) => { + if (branch.current || !branch.worktreePath) return false; + if (path.resolve(branch.worktreePath) === path.resolve(cwd)) return false; + return ( + worktreeBranchEntries === null || + worktreeBranchEntries.some( + (entry) => + entry.branchName === branch.name && entry.worktreePath === branch.worktreePath, + ) + ); + }), + (branch) => + readWorkingTreeChangeGroups(branch.worktreePath!).pipe( + Effect.map((result): VcsPanelWorktreeChangeSet | null => + changeGroupsHaveFiles(result.changeGroups) + ? { + branchName: branch.name, + worktreePath: branch.worktreePath!, + current: false, + lastActivityAt: branch.lastActivityAt ?? null, + changeGroups: result.changeGroups, + } + : null, + ), + Effect.orElseSucceed(() => null), + ), + { concurrency: 4 }, + ).pipe( + Effect.map((sets) => + sets + .filter((set): set is VcsPanelWorktreeChangeSet => set !== null) + .toSorted((left, right) => { + const leftTime = Date.parse(left.lastActivityAt ?? ""); + const rightTime = Date.parse(right.lastActivityAt ?? ""); + const activity = + (Number.isFinite(rightTime) ? rightTime : 0) - + (Number.isFinite(leftTime) ? leftTime : 0); + return activity !== 0 ? activity : left.branchName.localeCompare(right.branchName); + }), + ), + ); + }); + + const readFullSnapshot = Effect.fn("readFullSnapshot")(function* (cwd: string) { + const [ + localStatus, + localBranchesOutput, + worktreeListOutput, + workingTree, + remotesOutput, + stashes, + ] = yield* Effect.all( + [ + workflow + .status({ cwd }, { includePullRequest: false }) + .pipe(Effect.mapError(asGitCommandError("vcs.panel.status", cwd, ["status"]))), + runResult("vcs.panel.localBranches", cwd, LOCAL_BRANCHES_WITH_WORKTREE_PATH_ARGS, { + allowNonZeroExit: true, + }).pipe( + Effect.flatMap((result) => { + if (result.exitCode === 0) return Effect.succeed(result.stdout); + const detail = result.stderr.trim() || result.stdout.trim(); + return isUnsupportedWorktreePathFormat(detail) + ? run("vcs.panel.localBranches", cwd, LOCAL_BRANCHES_WITHOUT_WORKTREE_PATH_ARGS) + : Effect.fail( + gitError( + "vcs.panel.localBranches", + cwd, + LOCAL_BRANCHES_WITH_WORKTREE_PATH_ARGS, + detail, + ), + ); + }), + ), + run("vcs.panel.worktrees", cwd, ["worktree", "list", "--porcelain"], { + allowNonZeroExit: true, + }), + readWorkingTreeChangeGroups(cwd), + run("vcs.panel.remotes", cwd, ["remote", "-v"]), + run("vcs.panel.stashes", cwd, ["stash", "list", "--format=%gd%x09%H%x09%cI%x09%gs"]), + ], + { concurrency: "unbounded" }, + ); + + const localBranches = yield* Effect.forEach( + parseLocalBranches( + localBranchesOutput, + parseWorktreeBranchPaths(worktreeListOutput), + localStatus.isDefaultRef ? localStatus.refName : null, + ), + branchWithExistingWorktreePath, + { concurrency: "unbounded" }, + ); + const remotes = parseRemoteVerbose(remotesOutput); + const remotesWithBranches = yield* Effect.forEach( + remotes, + (remote) => + run("vcs.panel.remoteBranches", cwd, [ + "branch", + "-r", + "--list", + `${remote.name}/*`, + "--format=%(refname:lstrip=2)%09%(committerdate:iso-strict)", + ]).pipe( + Effect.map((branchesOutput) => ({ + ...remote, + branches: parseRemoteBranches(branchesOutput, remote.name), + })), + Effect.orElseSucceed(() => remote), + ), + { concurrency: "unbounded" }, + ); + const defaultCompareRef = + localBranches.find((ref) => ref.isDefault)?.name ?? + localBranches.find((ref) => !ref.current)?.name ?? + null; + const forkBranches = yield* actionableForkBranches(cwd, localBranches, remotesWithBranches); + const worktreeBranchEntries = parseWorktreeBranchEntries(worktreeListOutput); + const worktreeChangeSets = yield* readWorktreeChangeSets( + cwd, + localBranches, + worktreeBranchEntries, + ); + return { + status: panelStatusFromLocal(localStatus, workingTree.porcelain), + changeGroups: workingTree.changeGroups, + worktreeChangeSets, + localBranches, + branchDetails: [], + remotes: remotesWithBranches, + actionableForkBranches: forkBranches, + stashes: parseStashes(stashes), + recentCommits: [], + defaultCompareRef, + }; + }); + + const readWorkingTreeSnapshot = Effect.fn("readWorkingTreeSnapshot")(function* ( + cwd: string, + cached: VcsPanelSnapshotResult, + ) { + const [localStatus, workingTree] = yield* Effect.all( + [ + workflow + .status({ cwd }, { includePullRequest: false }) + .pipe(Effect.mapError(asGitCommandError("vcs.panel.status", cwd, ["status"]))), + readWorkingTreeChangeGroups(cwd), + ], + { concurrency: "unbounded" }, + ); + const status = panelStatusFromLocal(localStatus, workingTree.porcelain); + const worktreeChangeSets = yield* readWorktreeChangeSets(cwd, cached.localBranches, null); + return { status, workingTree, worktreeChangeSets }; + }); + + const repositoryStatusChanged = (left: VcsStatusResult, right: VcsStatusResult): boolean => + left.isRepo !== right.isRepo || + left.hasPrimaryRemote !== right.hasPrimaryRemote || + left.isDefaultRef !== right.isDefaultRef || + left.refName !== right.refName || + left.hasUpstream !== right.hasUpstream || + left.aheadCount !== right.aheadCount || + left.behindCount !== right.behindCount || + left.aheadOfDefaultCount !== right.aheadOfDefaultCount || + JSON.stringify(left.sourceControlProvider ?? null) !== + JSON.stringify(right.sourceControlProvider ?? null) || + JSON.stringify(left.pr) !== JSON.stringify(right.pr); + + const snapshot: SourceControlPanelService["Service"]["snapshot"] = Effect.fn("snapshot")( + function* (input) { + const cacheKey = path.resolve(input.cwd); + yield* Cache.invalidate(enrichmentStatusCache, cacheKey); + const request = yield* Ref.modify(snapshotCacheRef, (state) => { + const requestId = state.nextRequestId + 1; + const cached = state.snapshotsByCwd.get(cacheKey) ?? null; + const latestFullRequest = state.latestFullRequestByCwd.get(cacheKey) ?? 0; + const completedFullRequest = state.completedFullRequestByCwd.get(cacheKey) ?? 0; + const full = + input.refresh !== "working-tree" || + cached === null || + latestFullRequest > completedFullRequest; + const latestRequestByCwd = setBoundedMapEntry( + state.latestRequestByCwd, + cacheKey, + requestId, + PANEL_SNAPSHOT_CACHE_CAPACITY, + ); + const latestFullRequestByCwd = full + ? setBoundedMapEntry( + state.latestFullRequestByCwd, + cacheKey, + requestId, + PANEL_SNAPSHOT_CACHE_CAPACITY, + ) + : state.latestFullRequestByCwd; + return [ + { + requestId, + cached, + full, + }, + { ...state, nextRequestId: requestId, latestRequestByCwd, latestFullRequestByCwd }, + ] as const; + }); + + const refsFingerprint = NodeCrypto.createHash("sha256") + .update( + yield* run("vcs.panel.refsFingerprint", input.cwd, [ + "for-each-ref", + "--format=%(refname)%09%(objectname)", + ]), + ) + .digest("hex"); + let nextSnapshot: VcsPanelSnapshotResult; + if ( + !request.full && + request.cached !== null && + request.cached.refsFingerprint === refsFingerprint + ) { + const incremental = yield* readWorkingTreeSnapshot(input.cwd, request.cached); + nextSnapshot = repositoryStatusChanged(request.cached.status, incremental.status) + ? yield* readFullSnapshot(input.cwd) + : { + ...request.cached, + status: incremental.status, + changeGroups: incremental.workingTree.changeGroups, + worktreeChangeSets: incremental.worktreeChangeSets, + }; + } else { + nextSnapshot = yield* readFullSnapshot(input.cwd).pipe( + Effect.ensuring( + Ref.update(snapshotCacheRef, (state) => ({ + ...state, + completedFullRequestByCwd: setBoundedMapEntry( + state.completedFullRequestByCwd, + cacheKey, + Math.max(state.completedFullRequestByCwd.get(cacheKey) ?? 0, request.requestId), + PANEL_SNAPSHOT_CACHE_CAPACITY, + ), + })), + ), + ); + } + + nextSnapshot = { ...nextSnapshot, refsFingerprint }; + yield* Ref.update(snapshotCacheRef, (state) => { + if (state.latestRequestByCwd.get(cacheKey) !== request.requestId) { + return state; + } + const snapshotsByCwd = setBoundedMapEntry( + state.snapshotsByCwd, + cacheKey, + nextSnapshot, + PANEL_SNAPSHOT_CACHE_CAPACITY, + ); + return { ...state, snapshotsByCwd }; + }); + return nextSnapshot; + }, + ); + + const actions = makeSourceControlPanelActions({ + generatedCommitMessage, + generatedStashMessage, + invalidateRefs: git.invalidateRefs, + refExists, + run, + snapshot, + upstreamForRef, + withTemporaryIntentToAddIndex, + withTemporarySelectedIndex, + withStashMutation, + workflow, + }); + + return SourceControlPanelService.of({ + snapshot, + branchDetails: (input) => + branchDetails( + input.cwd, + input.branch, + input.defaultCompareRef, + input.compareBaseRef, + input.deferCommitFiles, + ), + commitFiles: (input) => + Cache.get(commitFilesCache, JSON.stringify([path.resolve(input.cwd), input.sha])).pipe( + Effect.map((files) => ({ files })), + ), + branchCommits: (input) => + branchCommits( + input.cwd, + input.branch, + input.baseRef, + input.kind, + input.skip, + input.limit, + input.deferCommitFiles, + ), + stashDetails: (input) => stashDetails(input.cwd, input.stashRef), + enrichWorkingTreeFiles, + fetchAllRemotes, + ...actions, + }); +}); + +export const layer = Layer.effect(SourceControlPanelService, make()); diff --git a/apps/server/src/sourceControl/SourceControlPanelStatusParsers.ts b/apps/server/src/sourceControl/SourceControlPanelStatusParsers.ts new file mode 100644 index 000000000000..77ee18dce3e6 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelStatusParsers.ts @@ -0,0 +1,340 @@ +import type { + VcsPanelChangeGroup, + VcsPanelFileChange, + VcsPanelFileStatus, + VcsPanelSnapshotResult, + VcsStatusLocalResult, + VcsStatusResult, +} from "@t3tools/contracts"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - Git paths use POSIX separators on every host; this parser is synchronous. +import * as NodePath from "node:path"; + +export function fileChangesRelativeToCwd( + files: readonly VcsPanelFileChange[], + prefix: string, +): VcsPanelFileChange[] { + return files.map((file) => ({ + ...file, + path: prefix ? NodePath.posix.relative(prefix, file.path) : file.path, + originalPath: + prefix && file.originalPath + ? NodePath.posix.relative(prefix, file.originalPath) + : file.originalPath, + })); +} + +export function parseCount(value: string | undefined): number { + const parsed = Number.parseInt(value ?? "0", 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function readNulField(output: string, startIndex: number) { + const endIndex = output.indexOf("\0", startIndex); + if (endIndex < 0) return { value: output.slice(startIndex), nextIndex: output.length }; + return { value: output.slice(startIndex, endIndex), nextIndex: endIndex + 1 }; +} + +export function parseNumstat( + output: string, + prefix = "", +): Map { + const stats = new Map(); + if (output.includes("\0")) { + let index = 0; + while (index < output.length) { + const headerEndIndex = output.indexOf("\t", index); + if (headerEndIndex < 0) break; + const insertionsRaw = output.slice(index, headerEndIndex); + const deletionEndIndex = output.indexOf("\t", headerEndIndex + 1); + if (deletionEndIndex < 0) break; + const deletionsRaw = output.slice(headerEndIndex + 1, deletionEndIndex); + index = deletionEndIndex + 1; + let pathField = readNulField(output, index); + index = pathField.nextIndex; + if (pathField.value === "") { + pathField = readNulField(output, index); + index = pathField.nextIndex; + const renamedPathField = readNulField(output, index); + index = renamedPathField.nextIndex; + pathField = renamedPathField; + } + if (!pathField.value) continue; + stats.set(prefix ? NodePath.posix.relative(prefix, pathField.value) : pathField.value, { + insertions: parseCount(insertionsRaw), + deletions: parseCount(deletionsRaw), + }); + } + return stats; + } + for (const line of output.split("\n")) { + const [insertionsRaw, deletionsRaw, path] = line.split("\t"); + if (!path) continue; + stats.set(prefix ? NodePath.posix.relative(prefix, path) : path, { + insertions: parseCount(insertionsRaw), + deletions: parseCount(deletionsRaw), + }); + } + return stats; +} + +export function mergeNumstats( + maps: Iterable>, +): Map { + const merged = new Map(); + for (const map of maps) { + for (const [path, stats] of map) { + const existing = merged.get(path); + merged.set(path, { + insertions: (existing?.insertions ?? 0) + stats.insertions, + deletions: (existing?.deletions ?? 0) + stats.deletions, + }); + } + } + return merged; +} + +export function statusFromCode(code: string, fallback: VcsPanelFileStatus): VcsPanelFileStatus { + switch (code) { + case "A": + return "added"; + case "D": + return "deleted"; + case "R": + return "renamed"; + case "C": + return "copied"; + case "U": + return "conflicted"; + case "M": + return "modified"; + default: + return fallback; + } +} + +function decodeGitQuotedPath(path: string): string { + if (!path.startsWith('"') || !path.endsWith('"')) return path; + const bytes: number[] = []; + const inner = path.slice(1, -1); + for (let index = 0; index < inner.length; index += 1) { + const char = inner[index] ?? ""; + if (char !== "\\") { + bytes.push(...Buffer.from(char)); + continue; + } + const next = inner[index + 1]; + if (next === undefined) { + bytes.push("\\".charCodeAt(0)); + continue; + } + const octal = /^[0-7]{1,3}/u.exec(inner.slice(index + 1))?.[0]; + if (octal) { + bytes.push(Number.parseInt(octal, 8)); + index += octal.length; + continue; + } + index += 1; + switch (next) { + case "a": + bytes.push(0x07); + break; + case "b": + bytes.push(0x08); + break; + case "f": + bytes.push(0x0c); + break; + case "n": + bytes.push(0x0a); + break; + case "r": + bytes.push(0x0d); + break; + case "t": + bytes.push(0x09); + break; + case "v": + bytes.push(0x0b); + break; + default: + bytes.push(...Buffer.from(next)); + break; + } + } + return Buffer.from(bytes).toString("utf8"); +} + +function addChange( + target: VcsPanelFileChange[], + input: { + path: string; + originalPath: string | null; + status: VcsPanelFileStatus; + stats?: { insertions: number; deletions: number } | undefined; + }, +) { + target.push({ + path: input.path, + originalPath: input.originalPath, + status: input.status, + insertions: input.stats?.insertions ?? 0, + deletions: input.stats?.deletions ?? 0, + }); +} + +export function parsePorcelainStatus(input: { + status: string; + stagedFiles?: readonly VcsPanelFileChange[]; + stagedStats: Map; + unstagedStats: Map; + untrackedStats: Map; + unstagedFiles?: readonly VcsPanelFileChange[]; +}): VcsPanelChangeGroup[] { + const staged: VcsPanelFileChange[] = []; + const unstaged: VcsPanelFileChange[] = []; + const conflicts: VcsPanelFileChange[] = []; + + for (const line of input.status.split(/\r?\n/u)) { + if (line.length === 0 || line.startsWith("#")) continue; + if (line.startsWith("? ")) { + if (input.unstagedFiles !== undefined) continue; + const path = decodeGitQuotedPath(line.slice(2)); + addChange(unstaged, { + path, + originalPath: null, + status: "untracked", + stats: input.untrackedStats.get(path), + }); + continue; + } + if (line.startsWith("u ")) { + const fields = line.split(" "); + const path = decodeGitQuotedPath(fields.slice(10).join(" ")); + if (path.length > 0) { + addChange(conflicts, { + path, + originalPath: null, + status: "conflicted", + stats: input.unstagedStats.get(path) ?? input.stagedStats.get(path), + }); + } + continue; + } + + if (!line.startsWith("1 ") && !line.startsWith("2 ")) continue; + const xy = line.slice(2, 4); + const stagedCode = xy[0] ?? "."; + const unstagedCode = xy[1] ?? "."; + const isRename = line.startsWith("2 "); + const pathPart = isRename + ? line.split(" ").slice(9).join(" ") + : line.split(" ").slice(8).join(" "); + const [rawPath = "", rawOriginalPath = null] = pathPart.split("\t"); + const path = decodeGitQuotedPath(rawPath); + const originalPath = rawOriginalPath === null ? null : decodeGitQuotedPath(rawOriginalPath); + if (path.length === 0) continue; + if (stagedCode === "U" || unstagedCode === "U") { + addChange(conflicts, { + path, + originalPath, + status: "conflicted", + stats: input.unstagedStats.get(path) ?? input.stagedStats.get(path), + }); + continue; + } + if (stagedCode !== "." && input.stagedFiles === undefined) { + addChange(staged, { + path, + originalPath, + status: statusFromCode(stagedCode, "modified"), + stats: input.stagedStats.get(path), + }); + } + if (unstagedCode !== "." && input.unstagedFiles === undefined) { + addChange(unstaged, { + path, + originalPath, + status: statusFromCode(unstagedCode, "modified"), + stats: input.unstagedStats.get(path), + }); + } + } + + const sortFiles = (files: VcsPanelFileChange[]) => + files.toSorted((left, right) => left.path.localeCompare(right.path)); + return [ + { + kind: "staged" as const, + files: sortFiles(input.stagedFiles ? [...input.stagedFiles] : staged), + }, + { + kind: "unstaged" as const, + files: sortFiles(input.unstagedFiles ? [...input.unstagedFiles] : unstaged), + }, + { kind: "conflicts" as const, files: sortFiles(conflicts) }, + ]; +} + +export function untrackedPathsFromPorcelain(status: string): string[] { + return status + .split(/\r?\n/u) + .flatMap((line) => (line.startsWith("? ") ? [decodeGitQuotedPath(line.slice(2))] : [])); +} + +export function unstagedFilesFromPorcelainStatus(input: { + status: string; + unstagedStats?: Map; + untrackedStats?: Map; +}): readonly VcsPanelFileChange[] { + return ( + parsePorcelainStatus({ + status: input.status, + stagedStats: new Map(), + unstagedStats: input.unstagedStats ?? new Map(), + untrackedStats: input.untrackedStats ?? new Map(), + }).find((group) => group.kind === "unstaged")?.files ?? [] + ); +} + +function parsePorcelainBranchSync(status: string) { + let configuredUpstream = false; + let upstreamExists = false; + let aheadCount = 0; + let behindCount = 0; + + for (const line of status.split(/\r?\n/u)) { + if (line.startsWith("# branch.upstream ")) { + configuredUpstream = true; + continue; + } + // Git omits `branch.ab` when the configured upstream ref is gone. + if (line.startsWith("# branch.ab ")) { + upstreamExists = true; + for (const part of line.slice("# branch.ab ".length).split(" ")) { + if (part.startsWith("+")) { + const ahead = Number.parseInt(part.slice(1), 10); + if (Number.isFinite(ahead)) aheadCount = ahead; + } + if (part.startsWith("-")) { + const behind = Number.parseInt(part.slice(1), 10); + if (Number.isFinite(behind)) behindCount = behind; + } + } + } + } + + return { hasUpstream: configuredUpstream && upstreamExists, aheadCount, behindCount }; +} + +export function panelStatusFromLocal( + local: VcsStatusLocalResult | VcsStatusResult, + porcelain: string, +): VcsPanelSnapshotResult["status"] { + const sync = parsePorcelainBranchSync(porcelain); + return { + ...local, + ...sync, + aheadOfDefaultCount: "aheadOfDefaultCount" in local ? local.aheadOfDefaultCount : 0, + pr: null, + }; +} diff --git a/apps/server/src/sourceControl/SourceControlProvider.test.ts b/apps/server/src/sourceControl/SourceControlProvider.test.ts index 7e5324882799..bd2d339c9357 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.test.ts @@ -1,6 +1,9 @@ import { assert, it } from "@effect/vitest"; -import { transportSafeSourceControlErrorValue } from "./SourceControlProvider.ts"; +import { + sourceControlProviderError, + transportSafeSourceControlErrorValue, +} from "./SourceControlProvider.ts"; it("removes URL credentials, query parameters, and fragments from error transport values", () => { assert.strictEqual( @@ -17,3 +20,46 @@ it("normalizes control characters and bounds error transport values", () => { `owner/repo ${"x".repeat(245)}`, ); }); + +it("keeps provider wrapper messages structural while sanitizing the transport cause", () => { + const error = sourceControlProviderError({ + provider: "gitlab", + operation: "listChangeRequests", + cwd: "/repo", + reference: "https://user:token@gitlab.example.test/group/repo?token=secret", + error: new Error("raw upstream https://user:token@gitlab.example.test/group/repo?token=secret"), + }); + + assert.strictEqual(error.detail, "Source control provider operation failed."); + assert.strictEqual( + error.message, + "Source control provider gitlab failed in listChangeRequests: Source control provider operation failed.", + ); + assert.strictEqual(error.message.includes("token"), false); + assert.deepStrictEqual(error.cause, { + name: "Error", + message: "raw upstream https://gitlab.example.test/group/repo", + }); +}); + +it("preserves finite rate-limit fields in the transport cause", () => { + const error = sourceControlProviderError({ + provider: "bitbucket", + operation: "listChangeRequests", + cwd: "/repo", + error: { + _tag: "BitbucketResponseError", + failureKind: "rate-limited", + status: 429, + retryAt: 121_000, + detail: "Bitbucket returned HTTP 429.", + }, + }); + + assert.deepInclude(error.cause, { + _tag: "BitbucketResponseError", + failureKind: "rate-limited", + status: 429, + retryAt: 121_000, + }); +}); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index ec61691fdd42..ee37f4f068ea 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -3,12 +3,12 @@ import * as Effect from "effect/Effect"; import type { ChangeRequest, ChangeRequestState, - SourceControlProviderError, SourceControlProviderInfo, SourceControlProviderKind, SourceControlRepositoryCloneUrls, SourceControlRepositoryVisibility, } from "@t3tools/contracts"; +import { SourceControlProviderError } from "@t3tools/contracts"; export interface SourceControlLinkSubject { readonly title: string; @@ -36,6 +36,8 @@ export interface SourceControlRefSelector { } const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256; +const EMBEDDED_HTTP_URL_PATTERN = /https?:\/\/[^\s"'<>`]+/giu; +const EMBEDDED_HTTP_AUTHORITY_CREDENTIALS_PATTERN = /^(https?:\/\/)[^/?#@]*@/iu; /** * Sanitizes user-provided source-control identifiers before attaching them to @@ -48,21 +50,35 @@ export function transportSafeSourceControlErrorValue(value: string): string { const codePoint = character.codePointAt(0); printable += codePoint !== undefined && (codePoint < 32 || codePoint === 127) ? " " : character; } - const normalized = printable.trim().replace(/\s+/gu, " "); + const normalized = printable + .replace(EMBEDDED_HTTP_URL_PATTERN, transportSafeEmbeddedUrl) + .trim() + .replace(/\s+/gu, " "); let safe = normalized; + const parsedUrl = transportSafeUrl(normalized); + safe = parsedUrl ?? normalized; + + return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH); +} + +function transportSafeEmbeddedUrl(value: string): string { + return ( + transportSafeUrl(value) ?? value.replace(EMBEDDED_HTTP_AUTHORITY_CREDENTIALS_PATTERN, "$1") + ); +} + +function transportSafeUrl(value: string): string | null { try { - const url = new URL(normalized); + const url = new URL(value); url.username = ""; url.password = ""; url.search = ""; url.hash = ""; - safe = url.toString(); + return url.toString(); } catch { - // Plain repository and change-request identifiers are not URLs. + return null; } - - return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH); } export function parseSourceControlOwnerRef( @@ -92,6 +108,105 @@ export function sourceControlRefFromInput(input: { return input.source ?? parseSourceControlOwnerRef(input.headSelector); } +export interface SourceControlProviderCommandError { + readonly command?: string; + readonly detail?: string; + readonly message?: string; +} + +function readStringField(value: unknown, key: string): string | undefined { + if (typeof value !== "object" || value === null || !(key in value)) { + return undefined; + } + const field = (value as Record)[key]; + return typeof field === "string" ? field : undefined; +} + +function safeNumberField(error: unknown, key: string): Record { + if (typeof error !== "object" || error === null || !(key in error)) { + return {}; + } + const value = (error as Record)[key]; + return typeof value === "number" && Number.isFinite(value) ? { [key]: value } : {}; +} + +function normalizedSourceControlProviderCause(error: unknown) { + if (typeof error !== "object" || error === null) { + return { + message: transportSafeSourceControlErrorValue(String(error)), + }; + } + + return { + ...safeErrorField(error, "_tag"), + ...safeErrorField(error, "name"), + ...safeErrorField(error, "command"), + ...safeErrorField(error, "operation"), + ...safeErrorField(error, "failureKind"), + ...safeErrorField(error, "reference"), + ...safeErrorField(error, "repository"), + ...safeErrorField(error, "detail"), + ...safeErrorField(error, "message"), + ...safeNumberField(error, "status"), + ...safeNumberField(error, "retryAt"), + }; +} + +function safeErrorField(error: unknown, key: string): Record { + const value = readStringField(error, key); + return value === undefined ? {} : { [key]: transportSafeSourceControlErrorValue(value) }; +} + +export function sourceControlProviderError(input: { + readonly provider: SourceControlProviderKind; + readonly operation: string; + readonly cwd: string; + readonly error: unknown; + readonly detail?: string; + readonly reference?: string; + readonly repository?: string; +}): SourceControlProviderError { + const command = readStringField(input.error, "command"); + const detail = + input.detail ?? + readStringField(input.error, "detail") ?? + "Source control provider operation failed."; + + return new SourceControlProviderError({ + provider: input.provider, + operation: input.operation, + ...(command !== undefined ? { command: transportSafeSourceControlErrorValue(command) } : {}), + cwd: input.cwd, + ...(input.reference !== undefined + ? { reference: transportSafeSourceControlErrorValue(input.reference) } + : {}), + ...(input.repository !== undefined + ? { repository: transportSafeSourceControlErrorValue(input.repository) } + : {}), + detail: transportSafeSourceControlErrorValue(detail), + cause: normalizedSourceControlProviderCause(input.error), + }); +} + +export function repositoryPathFromRemoteUrl(remoteUrl: string): string | null { + const withoutSuffix = (value: string) => value.replace(/\.git$/u, ""); + const trimmed = remoteUrl.trim(); + if (trimmed.length === 0) return null; + + try { + const url = new URL(trimmed); + const path = withoutSuffix(decodeURIComponent(url.pathname).replace(/^\/+/u, "").trim()); + return path.length > 0 ? path : null; + } catch { + const scpLike = /^(?:[^@/\s]+@)?[^:/\s]+:(.+)$/u.exec(trimmed); + if (scpLike?.[1]) { + const path = withoutSuffix(scpLike[1].replace(/^\/+/u, "").trim()); + return path.length > 0 ? path : null; + } + return null; + } +} + export class SourceControlProvider extends Context.Service< SourceControlProvider, { @@ -102,7 +217,7 @@ export class SourceControlProvider extends Context.Service< readonly cwd: string; readonly context?: SourceControlProviderContext; readonly source?: SourceControlRefSelector; - readonly headSelector: string; + readonly headSelector?: string; readonly state: ChangeRequestState | "all"; readonly limit?: number; }) => Effect.Effect, SourceControlProviderError>; @@ -126,6 +241,12 @@ export class SourceControlProvider extends Context.Service< readonly context?: SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; + readonly getCommitAvatarUrl: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly sha: string; + readonly authorEmail?: string | null; + }) => Effect.Effect; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 422b2c991040..a370912ac93f 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -100,6 +100,14 @@ function unsupportedProvider( repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository), detail: `No ${kind} source control provider is registered.`, }), + getCommitAvatarUrl: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "getCommitAvatarUrl", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.sha), + detail: `No ${kind} source control provider is registered.`, + }), createRepository: (input) => new SourceControlProviderError({ provider: kind, @@ -183,6 +191,11 @@ function bindProviderContext( ...input, context: input.context ?? context, }), + getCommitAvatarUrl: (input) => + provider.getCommitAvatarUrl({ + ...input, + context: input.context ?? context, + }), createRepository: (input) => provider.createRepository(input), getDefaultBranch: (input) => provider.getDefaultBranch({ diff --git a/apps/server/src/sourceControl/SourceControlRateLimit.test.ts b/apps/server/src/sourceControl/SourceControlRateLimit.test.ts index e46005c73f0e..0e644aeaf9e8 100644 --- a/apps/server/src/sourceControl/SourceControlRateLimit.test.ts +++ b/apps/server/src/sourceControl/SourceControlRateLimit.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; +import { sourceControlProviderError } from "./SourceControlProvider.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const github = { provider: "github" as const, host: "github.com" }; @@ -136,3 +137,109 @@ it.effect("keeps a fresh provider reset from an older request", () => assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 61_000); }).pipe(Effect.provide(SourceControlRateLimit.layer)), ); + +it.effect("shares provider backoff with best-effort panel reads", () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + let attempts = 0; + const rateLimited = sourceControlProviderError({ + provider: "bitbucket", + operation: "listChangeRequests", + cwd: "/repo", + error: { + _tag: "BitbucketResponseError", + status: 429, + retryAt: 121_000, + detail: "Bitbucket returned HTTP 429.", + }, + }); + + yield* SourceControlRateLimit.protectProviderRequest({ + limits, + provider: "bitbucket", + baseUrl: "https://Bitbucket.example.test:8443/path", + effect: Effect.sync(() => { + attempts += 1; + }).pipe(Effect.andThen(Effect.fail(rateLimited))), + }).pipe(Effect.exit); + + assert.equal( + (yield* Effect.flip( + limits.check({ provider: "bitbucket", host: "bitbucket.example.test:8443" }), + )).retryAt, + 121_000, + ); + + assert.equal(yield* limits.check({ provider: "bitbucket", host: "bitbucket.example.test" }), 0); + assert.equal( + yield* limits.check({ provider: "bitbucket", host: "bitbucket.example.test:9443" }), + 0, + ); + + const paused = yield* SourceControlRateLimit.protectProviderRequest({ + limits, + provider: "bitbucket", + baseUrl: "https://bitbucket.example.test:8443/other", + effect: Effect.sync(() => { + attempts += 1; + }), + }).pipe(Effect.flip); + + assert.equal(attempts, 1); + assert.deepInclude(paused, { + _tag: "SourceControlRateLimitPausedError", + provider: "bitbucket", + host: "bitbucket.example.test:8443", + retryAt: 121_000, + }); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); + +it.effect("does not pause panel reads after ordinary provider failures", () => + Effect.gen(function* () { + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const failed = sourceControlProviderError({ + provider: "gitlab", + operation: "getCommitAvatarUrl", + cwd: "/repo", + error: { _tag: "GitLabCliCommandError" }, + detail: "Provider request failed.", + }); + + yield* SourceControlRateLimit.protectProviderRequest({ + limits, + provider: "gitlab", + baseUrl: "https://gitlab.example.test", + effect: Effect.fail(failed), + }).pipe(Effect.exit); + + assert.equal(yield* limits.check({ provider: "gitlab", host: "gitlab.example.test" }), 0); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); + +it.effect("panel reads honor GitHub adapter cooldowns on enterprise ports", () => + Effect.gen(function* () { + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const key = { provider: "github" as const, host: "github.example.test:8443" }; + const lease = yield* limits.check(key); + yield* limits.recordRateLimit({ ...key, lease }); + let attempts = 0; + const read = (baseUrl: string) => + SourceControlRateLimit.protectProviderRequest({ + limits, + provider: "github", + baseUrl, + effect: Effect.sync(() => { + attempts += 1; + }), + }); + + const paused = yield* read("https://GitHub.example.test:8443/api/v3").pipe(Effect.flip); + assert.equal(paused._tag, "SourceControlRateLimitPausedError"); + assert.equal(attempts, 0); + yield* read("https://github.example.test:9443/api/v3"); + yield* read("https://github.example.test/api/v3"); + assert.equal(attempts, 2); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); diff --git a/apps/server/src/sourceControl/SourceControlRateLimit.ts b/apps/server/src/sourceControl/SourceControlRateLimit.ts index dc6242a60eb6..ceb4f69f08f5 100644 --- a/apps/server/src/sourceControl/SourceControlRateLimit.ts +++ b/apps/server/src/sourceControl/SourceControlRateLimit.ts @@ -3,9 +3,11 @@ import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Predicate from "effect/Predicate"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { + type SourceControlProviderError, SourceControlProviderKind as SourceControlProviderKindSchema, type SourceControlProviderKind, } from "@t3tools/contracts"; @@ -86,6 +88,63 @@ export function retryAtFromHeader(value: string | undefined, now: number): numbe return Number.isFinite(retryAt) && retryAt > now ? retryAt : undefined; } +export function providerHost(baseUrl: string, provider: SourceControlProviderKind): string { + try { + const host = new URL(baseUrl).host.trim().toLowerCase(); + return host.length > 0 ? host : provider; + } catch { + return provider; + } +} + +function providerRateLimit( + error: SourceControlProviderError, +): { readonly retryAt?: number } | null { + const cause = error.cause; + if (!Predicate.isObject(cause)) return null; + + const tag = Predicate.isString(cause._tag) ? cause._tag.toLowerCase() : ""; + const failureKind = Predicate.isString(cause.failureKind) ? cause.failureKind.toLowerCase() : ""; + const detail = Predicate.isString(cause.detail) ? cause.detail.toLowerCase() : ""; + const status = Predicate.isNumber(cause.status) ? cause.status : undefined; + const isRateLimited = + tag.includes("ratelimit") || + failureKind === "rate-limited" || + status === 429 || + detail.includes("rate limit") || + detail.includes("too many requests"); + if (!isRateLimited) return null; + + const retryAt = + Predicate.isNumber(cause.retryAt) && Number.isFinite(cause.retryAt) ? cause.retryAt : undefined; + return retryAt === undefined ? {} : { retryAt }; +} + +export function protectProviderRequest(input: { + readonly limits: SourceControlRateLimit["Service"]; + readonly provider: SourceControlProviderKind; + readonly baseUrl: string; + readonly effect: Effect.Effect; +}) { + const key = { + provider: input.provider, + host: providerHost(input.baseUrl, input.provider), + }; + return input.limits.check(key).pipe( + Effect.flatMap((lease) => + input.effect.pipe( + Effect.tap(() => input.limits.recordSuccess({ ...key, lease })), + Effect.tapError((error) => { + const rateLimit = providerRateLimit(error); + return rateLimit === null + ? Effect.void + : input.limits.recordRateLimit({ ...key, lease, ...rateLimit }); + }), + ), + ), + ); +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const entries = yield* Ref.make>(new Map()); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index ea90320eb8b6..d10cc61e96aa 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -3,8 +3,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -38,6 +38,7 @@ function makeProvider( createChangeRequest: () => unsupported("createChangeRequest"), getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS), createRepository: () => Effect.succeed(CLONE_URLS), + getCommitAvatarUrl: () => Effect.succeed(null), getDefaultBranch: () => Effect.succeed(null), checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), ...overrides, diff --git a/apps/server/src/textGeneration/SourceControlWriting.ts b/apps/server/src/textGeneration/SourceControlWriting.ts new file mode 100644 index 000000000000..9f0a8583f715 --- /dev/null +++ b/apps/server/src/textGeneration/SourceControlWriting.ts @@ -0,0 +1,125 @@ +import * as ByteSize from "effect/ByteSize"; +import type { + ModelSelection, + ServerProvider, + SourceControlWritingStyleSettings, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; + +import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; +import { + conventionalCommitsTextGenerationPolicy, + customTextGenerationPolicy, + repositoryConventionsTextGenerationPolicy, +} from "./TextGenerationPresets.ts"; + +export interface SourceControlTextGenerationSettings { + readonly modelSelection: ModelSelection; + readonly providers: ReadonlyArray>; + readonly style: SourceControlWritingStyleSettings; +} + +export type SourceControlWritingPolicyResolver = ( + cwd: string, + settings: SourceControlTextGenerationSettings, +) => Effect.Effect; + +export function makeSourceControlRepositoryInstructionReader( + fileSystem: FileSystem.FileSystem, + path: Path.Path, +) { + return Effect.fn("readSourceControlRepositoryInstructions")( + function* (cwd: string, fileName: string) { + const root = yield* fileSystem.realPath(cwd); + const instructionPath = yield* fileSystem.realPath(path.join(root, fileName)); + if (!instructionPath.startsWith(`${root}${path.sep}`)) return ""; + const info = yield* fileSystem.stat(instructionPath); + if (info.type !== "File" || info.size > ByteSize.bytes(20_000)) return ""; + return (yield* fileSystem.readFileString(instructionPath)).trim(); + }, + Effect.orElseSucceed(() => ""), + ); +} + +export function makeSourceControlWritingPolicyResolver(dependencies: { + readonly runGit: (cwd: string, args: readonly string[]) => Effect.Effect; + readonly readRepositoryInstructions: (cwd: string, fileName: string) => Effect.Effect; +}): SourceControlWritingPolicyResolver { + const readRecentCommitSubjects = (cwd: string) => + dependencies.runGit(cwd, ["log", "-n", "20", "--no-merges", "--pretty=format:%s"]).pipe( + Effect.map((output) => + output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ), + Effect.orElseSucceed(() => []), + ); + + return Effect.fn("resolveSourceControlWritingPolicyForRepository")(function* ( + cwd: string, + settings: SourceControlTextGenerationSettings, + ) { + if (settings.style.mode !== "repo_conventions") { + return resolveSourceControlWritingPolicy(settings.style); + } + + const [recentCommitSubjects, agentInstructions] = yield* Effect.all( + [readRecentCommitSubjects(cwd), dependencies.readRepositoryInstructions(cwd, "AGENTS.md")], + { concurrency: "unbounded" }, + ); + const isClaudeWriter = + settings.modelSelection.instanceId === "claudeAgent" || + settings.providers.some( + (provider) => + provider.instanceId === settings.modelSelection.instanceId && + provider.driver === "claudeAgent", + ); + const claudeInstructions = isClaudeWriter + ? yield* dependencies.readRepositoryInstructions(cwd, "CLAUDE.md") + : ""; + + return resolveSourceControlWritingPolicy(settings.style, recentCommitSubjects, [ + ...(agentInstructions ? [`Local AGENTS.md:\n${agentInstructions}`] : []), + ...(claudeInstructions ? [`Local CLAUDE.md:\n${claudeInstructions}`] : []), + ]); + }); +} + +function resolveSourceControlWritingPolicy( + style: SourceControlWritingStyleSettings, + recentCommitSubjects: readonly string[] = [], + repositoryInstructionSections: readonly string[] = [], +): TextGenerationPolicy { + switch (style.mode) { + case "conventional_commits": + return conventionalCommitsTextGenerationPolicy; + case "custom": + return customTextGenerationPolicy( + style.customInstructions + ? { + commitInstructions: style.customInstructions, + changeRequestInstructions: style.customInstructions, + } + : {}, + ); + case "repo_conventions": { + const examples = [ + ...(recentCommitSubjects.length > 0 + ? [["Recent commit subjects from this repository:", ...recentCommitSubjects].join("\n")] + : []), + ...repositoryInstructionSections, + ].join("\n\n"); + if (!examples) { + return repositoryConventionsTextGenerationPolicy; + } + return { + ...repositoryConventionsTextGenerationPolicy, + commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`, + changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`, + }; + } + } +} diff --git a/apps/server/src/utils/CanonicalPath.ts b/apps/server/src/utils/CanonicalPath.ts new file mode 100644 index 000000000000..659f9b62bed7 --- /dev/null +++ b/apps/server/src/utils/CanonicalPath.ts @@ -0,0 +1,38 @@ +// @effect-diagnostics nodeBuiltinImport:off - Node's native realpath expands Windows 8.3 aliases. +import * as NodeFS from "node:fs"; + +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +class NativeRealPathError extends Data.TaggedError("NativeRealPathError")<{ + readonly path: string; +}> {} + +/** + * Return the native canonical form of an existing path when possible. + * + * Node's native realpath is important on Windows because it expands 8.3 path aliases. The Effect + * filesystem remains the portable fallback, and callers still receive their original path when the + * target does not exist or neither filesystem implementation can resolve it. + */ +export const canonicalizeExistingPath = Effect.fn("CanonicalPath.canonicalizeExistingPath")( + function* (fileSystem: FileSystem.FileSystem, path: string) { + return yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + NodeFS.realpath.native(path, (error, resolvedPath) => { + if (error) { + reject(error); + } else { + resolve(resolvedPath); + } + }); + }), + catch: () => new NativeRealPathError({ path }), + }).pipe( + Effect.catch(() => fileSystem.realPath(path)), + Effect.orElseSucceed(() => path), + ); + }, +); diff --git a/apps/server/src/vcs/GitCommandTimeout.test.ts b/apps/server/src/vcs/GitCommandTimeout.test.ts new file mode 100644 index 000000000000..04225197885b --- /dev/null +++ b/apps/server/src/vcs/GitCommandTimeout.test.ts @@ -0,0 +1,42 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { GIT_COMMAND_TIMEOUT_MS, resolveGitCommandTimeoutMs } from "./GitCommandTimeout.ts"; + +describe("resolveGitCommandTimeoutMs", () => { + it("gives fetch, push, and pull commands a five-minute timeout", () => { + for (const command of ["fetch", "push", "pull"]) { + assert.strictEqual(resolveGitCommandTimeoutMs([command]), GIT_COMMAND_TIMEOUT_MS.network); + } + }); + + it("gives commits enough time to run repository hooks", () => { + assert.strictEqual(resolveGitCommandTimeoutMs(["commit", "-m", "message"]), 600_000); + }); + + it("recognizes network commands after Git global options", () => { + assert.strictEqual( + resolveGitCommandTimeoutMs(["--git-dir", "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/repo/.git", "fetch", "--all"]), + GIT_COMMAND_TIMEOUT_MS.network, + ); + assert.strictEqual( + resolveGitCommandTimeoutMs(["-c", "credential.helper=", "push", "origin", "main"]), + GIT_COMMAND_TIMEOUT_MS.network, + ); + }); + + it("keeps local commands at thirty seconds", () => { + assert.strictEqual( + resolveGitCommandTimeoutMs(["status", "--short"]), + GIT_COMMAND_TIMEOUT_MS.local, + ); + assert.strictEqual( + resolveGitCommandTimeoutMs(["checkout", "push"]), + GIT_COMMAND_TIMEOUT_MS.local, + ); + }); + + it("preserves an explicit operation timeout", () => { + assert.strictEqual(resolveGitCommandTimeoutMs(["fetch", "origin"], 5_000), 5_000); + assert.isNull(resolveGitCommandTimeoutMs(["fetch", "origin"], null)); + }); +}); diff --git a/apps/server/src/vcs/GitCommandTimeout.ts b/apps/server/src/vcs/GitCommandTimeout.ts new file mode 100644 index 000000000000..0bf5503100bd --- /dev/null +++ b/apps/server/src/vcs/GitCommandTimeout.ts @@ -0,0 +1,51 @@ +export const GIT_COMMAND_TIMEOUT_MS = { + local: 30_000, + network: 5 * 60_000, + commit: 10 * 60_000, +} as const; + +export type GitCommandTimeoutOverride = number | null; +export type GitCommandTimeoutInput = GitCommandTimeoutOverride | undefined; +type ResolvedGitCommandTimeout = Input extends null + ? null + : number; + +const NETWORK_GIT_SUBCOMMANDS = new Set(["fetch", "pull", "push"]); +const GIT_GLOBAL_OPTIONS_WITH_VALUES = new Set([ + "-C", + "-c", + "--config-env", + "--git-dir", + "--namespace", + "--super-prefix", + "--work-tree", +]); + +function gitSubcommand(args: readonly string[]): string | null { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined) continue; + if (GIT_GLOBAL_OPTIONS_WITH_VALUES.has(arg)) { + index += 1; + continue; + } + if (arg.startsWith("-")) continue; + return arg; + } + return null; +} + +export function resolveGitCommandTimeoutMs( + args: readonly string[], + timeoutMs?: Input, +): ResolvedGitCommandTimeout { + if (timeoutMs !== undefined) return timeoutMs as ResolvedGitCommandTimeout; + const subcommand = gitSubcommand(args); + const resolved = + subcommand === "commit" + ? GIT_COMMAND_TIMEOUT_MS.commit + : NETWORK_GIT_SUBCOMMANDS.has(subcommand ?? "") + ? GIT_COMMAND_TIMEOUT_MS.network + : GIT_COMMAND_TIMEOUT_MS.local; + return resolved as ResolvedGitCommandTimeout; +} diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 4df6d0eefc5c..c6806bcd79cd 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1116,3 +1116,46 @@ it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publi ), ); }); + +it.effect("GitVcsDriver applies subcommand-aware default timeouts", () => { + const calls: VcsProcess.VcsProcessInput[] = []; + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + + yield* driver.execute({ operation: "test.status", cwd: "/repo", args: ["status"] }); + yield* driver.execute({ operation: "test.fetch", cwd: "/repo", args: ["fetch", "origin"] }); + yield* driver.execute({ operation: "test.push", cwd: "/repo", args: ["push", "origin"] }); + yield* driver.execute({ operation: "test.pull", cwd: "/repo", args: ["pull", "--ff-only"] }); + yield* driver.execute({ + operation: "test.explicit", + cwd: "/repo", + args: ["fetch", "origin"], + timeoutMs: 5_000, + }); + + assert.deepStrictEqual( + calls.map((call) => call.timeoutMs), + [30_000, 300_000, 300_000, 300_000, 5_000], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ), + ), + ); +}); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 71ebeb1cfdb3..3249bb19974e 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -37,6 +37,7 @@ import { PATCH_RENDER_PREFIX_ARGS, splitNullSeparatedGitStdoutPaths, } from "./GitVcsDriverCore.ts"; +import { type GitCommandTimeoutOverride, resolveGitCommandTimeoutMs } from "./GitCommandTimeout.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -47,7 +48,7 @@ export interface ExecuteGitInput { readonly stdin?: string; readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean; - readonly timeoutMs?: number | null; + readonly timeoutMs?: GitCommandTimeoutOverride; readonly maxOutputBytes?: number; readonly appendTruncationMarker?: boolean; /** @@ -326,6 +327,7 @@ export class GitVcsDriver extends Context.Service< readonly listRefs: ( input: VcsListRefsInput, ) => Effect.Effect; + readonly invalidateRefs: (cwd: string) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, @@ -498,7 +500,7 @@ const gitCommand = ( ...(options?.allowNonZeroExit !== undefined ? { allowNonZeroExit: options.allowNonZeroExit } : {}), - ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + timeoutMs: resolveGitCommandTimeoutMs(args, options?.timeoutMs), ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), ...(options?.outputMode !== undefined ? { outputMode: options.outputMode } : {}), ...(options?.appendTruncationMarker !== undefined diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 85c4d1a59d1d..fcdb0ddcbc2d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -12,8 +12,8 @@ import * as Logger from "effect/Logger"; import * as Metric from "effect/Metric"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Queue from "effect/Queue"; import * as Result from "effect/Result"; import * as Scope from "effect/Scope"; import * as Schema from "effect/Schema"; @@ -35,6 +35,10 @@ import { parseGitCheckoutProgressLine, splitNullSeparatedGitStdoutPaths, } from "./GitVcsDriverCore.ts"; +import { + makeSourceControlPanelActions, + type SourceControlPanelActionDependencies, +} from "../sourceControl/SourceControlPanelActions.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); @@ -203,9 +207,15 @@ it.effect("bounds Git bursts across drivers without timing out queued commands", }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); -it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])( +it.effect.each([ + { args: ["push"], timeoutMs: null }, + { args: ["push"], timeoutMs: 30_001 }, + { args: ["push"], timeoutMs: undefined }, + { args: ["fetch", "origin"], timeoutMs: undefined }, + { args: ["commit"], timeoutMs: undefined }, +])( "keeps all Git slots available with a pending command whose timeout is $timeoutMs", - ({ timeoutMs }) => + ({ args, timeoutMs }) => Effect.gen(function* () { const slowGate = yield* Deferred.make(); const fastGate = yield* Deferred.make(); @@ -217,7 +227,7 @@ it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])( active++; yield* Queue.offer(starts, undefined); const gate = - ChildProcess.isStandardCommand(command) && command.args[0] === "push" + ChildProcess.isStandardCommand(command) && command.args[0] !== "status" ? slowGate : fastGate; return ChildProcessSpawner.makeHandle({ @@ -232,7 +242,12 @@ it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const slow = yield* driver - .execute({ operation: "test.slowGit", cwd: "/repo", args: ["push"], timeoutMs }) + .execute({ + operation: "test.slowGit", + cwd: "/repo", + args, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }) .pipe(Effect.forkChild); yield* Queue.take(starts); const burst = yield* Effect.all( @@ -259,8 +274,22 @@ for (const location of ["root", "nested", "worktree"] as const) { `skips clean filters while the ${location} index is locked and resumes after unlock`, () => Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; const fs = yield* FileSystem.FileSystem; + const lockChecks = yield* Queue.unbounded(); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provide(ServerConfigLayer), + Effect.provideService(FileSystem.FileSystem, { + ...fs, + exists: (path) => + fs + .exists(path) + .pipe( + Effect.tap((exists) => + path.endsWith("index.lock") ? Queue.offer(lockChecks, exists) : Effect.void, + ), + ), + }), + ); const path = yield* Path.Path; const repository = yield* makeTmpDir(); yield* initRepoWithCommit(repository); @@ -284,21 +313,70 @@ for (const location of ["root", "nested", "worktree"] as const) { yield* fs.makeDirectory(statusCwd, { recursive: true }); for (let poll = 0; poll < 3; poll++) { - const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result); + const pending = yield* driver + .statusDetailsLocal(statusCwd) + .pipe(Effect.result, Effect.forkChild); + assert.isTrue(yield* Queue.take(lockChecks)); + assert.isFalse(yield* fs.exists(runsPath)); + yield* TestClock.adjust("1 second"); + const result = yield* Fiber.join(pending); + assert.isTrue(yield* Queue.take(lockChecks)); assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked"); } assert.isFalse(yield* fs.exists(runsPath)); assert.isTrue(yield* fs.exists(lockPath)); + const pending = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.forkChild); + assert.isTrue(yield* Queue.take(lockChecks)); + assert.isFalse(yield* fs.exists(runsPath)); yield* fs.remove(lockPath); - const status = yield* driver.statusDetailsLocal(statusCwd); + yield* TestClock.adjust("1 second"); + const status = yield* Fiber.join(pending); + assert.isFalse(yield* Queue.take(lockChecks)); assert.isFalse(status.hasWorkingTreeChanges); assert.include(yield* fs.readFileString(runsPath), "clean"); }).pipe(Effect.provide(TestLayer)), ); } +it.effect("keeps synchronous spawn failures local to the Git request", () => { + const spawnError = Object.assign(new Error("spawn ENAMETOOLONG"), { code: "ENAMETOOLONG" }); + let attempts = 0; + const spawner = ChildProcessSpawner.make(() => { + attempts++; + if (attempts === 1) throw spawnError; + if (attempts === 2) return Effect.die(spawnError); + return Effect.succeed(makeSuccessfulHandle("git version test")); + }); + const layer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge( + Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ), + ); + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + for (let attempt = 0; attempt < 2; attempt++) { + const error = yield* driver + .execute({ operation: "test.spawn", cwd: "/repo", args: ["--version"] }) + .pipe(Effect.flip); + assert.equal(error._tag, "GitCommandError"); + assert.equal(error.detail, "Failed to spawn Git process."); + assert.strictEqual(error.cause, spawnError); + } + const result = yield* driver.execute({ + operation: "test.afterSpawnFailure", + cwd: "/repo", + args: ["--version"], + }); + assert.equal(result.stdout, "git version test"); + }).pipe(Effect.provide(layer)); +}); + it.effect("uses stable diagnostics for every parsed non-repository command", () => { const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; const spawner = ChildProcessSpawner.make((command) => @@ -333,7 +411,7 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () assert.deepStrictEqual(commands, [ { args: ["rev-parse", "--git-path", "index"], lcAll: "C" }, { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, - { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, + { args: ["branch", "--show-current"], lcAll: "C" }, { args: ["rev-parse", "--git-common-dir"], lcAll: "C" }, ]); }).pipe(Effect.provide(layer)); @@ -496,6 +574,85 @@ it.effect("coalesces concurrent ref pages into one repository snapshot", () => ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); +it.effect("exposes shared ref snapshot invalidation for raw Git mutation services", () => + Effect.scoped( + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + + const initialRefs = yield* driver.listRefs({ cwd, refresh: true }); + assert.equal( + initialRefs.refs.some((ref) => ref.name === "feature/raw-panel"), + false, + ); + + yield* git(cwd, ["branch", "feature/raw-panel"]); + const cachedRefs = yield* driver.listRefs({ cwd }); + assert.equal( + cachedRefs.refs.some((ref) => ref.name === "feature/raw-panel"), + false, + ); + + yield* driver.invalidateRefs(cwd); + const invalidatedRefs = yield* driver.listRefs({ cwd }); + assert.equal( + invalidatedRefs.refs.some((ref) => ref.name === "feature/raw-panel"), + true, + ); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("refreshes pull-request listRefs data after a ref-changing panel action", () => + Effect.scoped( + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const headSha = yield* git(cwd, ["rev-parse", "HEAD"]); + const branchName = "feature/panel-list-refs"; + const pullRequestRefsInput = { + cwd, + includeMatchingRemoteRefs: true, + limit: 2, + query: branchName, + } as const; + const before = yield* driver.listRefs({ ...pullRequestRefsInput, refresh: true }); + assert.equal( + before.refs.some((ref) => ref.name === branchName), + false, + ); + + const actions = makeSourceControlPanelActions({ + invalidateRefs: driver.invalidateRefs, + run: (operation, commandCwd, args, options) => + driver + .execute({ + operation, + cwd: commandCwd, + args: [...args], + timeoutMs: 10_000, + ...(options?.allowNonZeroExit !== undefined + ? { allowNonZeroExit: options.allowNonZeroExit } + : {}), + ...(options?.env !== undefined ? { env: options.env } : {}), + ...(options?.progress !== undefined ? { progress: options.progress } : {}), + }) + .pipe(Effect.map((result) => result.stdout)), + } as SourceControlPanelActionDependencies); + + yield* actions.createBranchFromCommit({ cwd, sha: headSha, branchName }); + + const after = yield* driver.listRefs(pullRequestRefsInput); + assert.equal( + after.refs.some((ref) => ref.name === branchName), + true, + ); + }), + ).pipe(Effect.provide(TestLayer)), +); + it.effect("retries an in-flight ref snapshot invalidated by a mutation", () => Effect.scoped( Effect.gen(function* () { @@ -1872,6 +2029,51 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports an unborn branch when its configured remote repository is unavailable", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const unavailableRemoteParent = yield* makeTmpDir("git-vcs-driver-missing-remote-"); + const pathService = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(cwd, [ + "remote", + "add", + "origin", + pathService.join(unavailableRemoteParent, "not-created.git"), + ]); + const branch = yield* git(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]); + + const status = yield* driver.statusDetailsRemote(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.branch, branch); + assert.equal(status.hasUpstream, false); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + }), + ); + + it.effect("reports an unborn branch when its remote exists without an uploaded branch", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-empty-remote-"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + const branch = yield* git(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]); + + const status = yield* driver.statusDetailsRemote(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.branch, branch); + assert.equal(status.hasUpstream, false); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + }), + ); + it.effect("reports remote status on unborn HEAD without failing", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2207,6 +2409,63 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("creates a suffixed tracking branch for colliding remote refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const origin = yield* makeTmpDir("git-vcs-driver-origin-"); + const upstream = yield* makeTmpDir("git-vcs-driver-upstream-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(origin, ["init", "--bare"]); + yield* git(upstream, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", origin]); + yield* git(cwd, ["remote", "add", "upstream", upstream]); + yield* git(cwd, ["push", "origin", `${initialBranch}:refs/heads/release`]); + yield* git(cwd, ["push", "upstream", `${initialBranch}:refs/heads/release`]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["fetch", "upstream"]); + yield* git(cwd, ["checkout", "-b", "release", "--track", "upstream/release"]); + yield* git(cwd, ["checkout", initialBranch]); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ + cwd, + refName: "origin/release", + }); + + assert.equal(result.refName, "release-1"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "release-1"); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/release", + ); + }), + ); + + it.effect("derives tracking branches from slashful remote names", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const slashfulRemote = yield* makeTmpDir("git-vcs-driver-slashful-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(slashfulRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "my-org/upstream", slashfulRemote]); + yield* git(cwd, ["push", "my-org/upstream", `${initialBranch}:refs/heads/effect-atom`]); + yield* git(cwd, ["fetch", "my-org/upstream"]); + yield* git(cwd, ["checkout", "-b", "effect-atom"]); + yield* git(cwd, ["checkout", initialBranch]); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ + cwd, + refName: "my-org/upstream/effect-atom", + }); + + assert.equal(result.refName, "effect-atom-1"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "effect-atom-1"); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "my-org/upstream/effect-atom", + ); + }), + ); + it.effect("returns the existing refName when rename source and target match", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2886,8 +3145,8 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); yield* Deferred.await(started); if (failure === "timeout") { - yield* TestClock.adjust("31 seconds"); - yield* TestClock.adjust("31 seconds"); + yield* TestClock.adjust("301 seconds"); + yield* TestClock.adjust("301 seconds"); } const result = yield* Fiber.join(fetching); assert.isTrue(Result.isFailure(result)); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0dd73af687f9..d785ad3f0b13 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -41,6 +41,8 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import { ServerConfig } from "../config.ts"; +import { canonicalizeExistingPath } from "../utils/CanonicalPath.ts"; +import { type GitCommandTimeoutOverride, resolveGitCommandTimeoutMs } from "./GitCommandTimeout.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const gitProcesses = Semaphore.makeUnsafe(8); @@ -78,13 +80,14 @@ const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); -const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ +export const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", GIT_TERMINAL_PROMPT: "0", SSH_ASKPASS: "", SSH_ASKPASS_REQUIRE: "never", } satisfies NodeJS.ProcessEnv); + const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ @@ -153,7 +156,7 @@ interface GitRefsSnapshot { interface ExecuteGitOptions { stdin?: string | undefined; - timeoutMs?: number | null | undefined; + timeoutMs?: GitCommandTimeoutOverride; allowNonZeroExit?: boolean | undefined; fallbackErrorDetail?: string | undefined; env?: NodeJS.ProcessEnv | undefined; @@ -400,7 +403,18 @@ function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): return null; } -function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { +function deriveLocalBranchNameFromRemoteRef( + branchName: string, + remoteNames: ReadonlyArray, +): string | null { + const parsedRemoteRef = parseRemoteRefWithRemoteNames( + branchName, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + if (parsedRemoteRef) { + return parsedRemoteRef.branchName; + } + const separatorIndex = branchName.indexOf("/"); if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { return null; @@ -831,138 +845,152 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const { worktreesDir } = yield* ServerConfig; const crypto = yield* Crypto.Crypto; - const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( - function* (input) { - const commandInput = { - ...input, - args: [...input.args], - } as const; - const timeoutMs = input.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : input.timeoutMs; - const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; - const appendTruncationMarker = input.appendTruncationMarker ?? false; - - const runGitCommand = Effect.fn("runGitCommand")(function* () { - const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( - Effect.provideService(Path.Path, path), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.mapError( - (cause) => - new GitCommandError({ - ...gitCommandContext(commandInput), - detail: "Failed to create Git trace monitor.", - cause, - }), - ), - ); - const child = yield* commandSpawner - .spawn( - ChildProcess.make("git", commandInput.args, { - cwd: commandInput.cwd, - env: { - ...process.env, - ...input.env, - ...trace2Monitor.env, - }, + const executeRaw = Effect.fnUntraced(function* ( + input: Parameters[0], + timeoutMs: number | null, + ) { + const commandInput = { + ...input, + args: [...input.args], + } as const; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const appendTruncationMarker = input.appendTruncationMarker ?? false; + + const runGitCommand = Effect.fn("runGitCommand")(function* () { + const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to create Git trace monitor.", + cause, }), - ) - .pipe( + ), + ); + const child = yield* Effect.suspend(() => + commandSpawner.spawn( + ChildProcess.make("git", commandInput.args, { + cwd: commandInput.cwd, + env: { + ...process.env, + ...input.env, + ...trace2Monitor.env, + }, + }), + ), + ).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to spawn Git process.", + cause, + }), + ), + // Node can throw synchronously for launch failures such as ENAMETOOLONG. + // They must fail this Git request, not the shared RPC connection. + Effect.catchDefect((cause) => + Effect.fail( + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to spawn Git process.", + cause, + }), + ), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectOutput( + commandInput, + child.stdout, + maxOutputBytes, + appendTruncationMarker, + input.progress?.onStdoutLine, + input.keepLineCallbacksAfterTruncation, + ), + collectOutput( + commandInput, + child.stderr, + maxOutputBytes, + appendTruncationMarker, + input.progress?.onStderrLine, + input.keepLineCallbacksAfterTruncation, + ), + child.exitCode.pipe( Effect.mapError( (cause) => new GitCommandError({ ...gitCommandContext(commandInput), - detail: "Failed to spawn Git process.", + detail: "Failed to read Git process exit code.", cause, }), ), - ); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectOutput( - commandInput, - child.stdout, - maxOutputBytes, - appendTruncationMarker, - input.progress?.onStdoutLine, - input.keepLineCallbacksAfterTruncation, - ), - collectOutput( - commandInput, - child.stderr, - maxOutputBytes, - appendTruncationMarker, - input.progress?.onStderrLine, - input.keepLineCallbacksAfterTruncation, - ), - child.exitCode.pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - ...gitCommandContext(commandInput), - detail: "Failed to read Git process exit code.", - cause, - }), - ), - ), - input.stdin === undefined - ? Effect.void - : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - ...gitCommandContext(commandInput), - detail: "Failed to write Git process input.", - cause, - }), - ), + ), + input.stdin === undefined + ? Effect.void + : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to write Git process input.", + cause, + }), ), - ], - { concurrency: "unbounded" }, - ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); - yield* trace2Monitor.flush; - - if (!input.allowNonZeroExit && exitCode !== 0) { - return yield* new GitCommandError({ - ...gitCommandContext(commandInput), - detail: "Git command exited with a non-zero status.", - exitCode, - stdoutLength: stdout.text.length, - stderrLength: stderr.text.length, - }); - } + ), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); + yield* trace2Monitor.flush; - return { + if (!input.allowNonZeroExit && exitCode !== 0) { + return yield* new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Git command exited with a non-zero status.", exitCode, - stdout: stdout.text, - stderr: stderr.text, - stdoutTruncated: stdout.truncated, - stderrTruncated: stderr.truncated, - } satisfies GitVcsDriver.ExecuteGitResult; - }); - - const execution = runGitCommand().pipe(Effect.scoped); - if (timeoutMs === null) { - return yield* execution; + stdoutLength: stdout.text.length, + stderrLength: stderr.text.length, + }); } - return yield* execution.pipe( - Effect.timeoutOption(timeoutMs), - Effect.flatMap((result) => - Effect.fromOption( - result, - () => + return { + exitCode, + stdout: stdout.text, + stderr: stderr.text, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + } satisfies GitVcsDriver.ExecuteGitResult; + }); + + const execution = runGitCommand().pipe(Effect.scoped); + if (timeoutMs === null) { + return yield* execution; + } + + return yield* execution.pipe( + Effect.timeoutOption(timeoutMs), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( new GitCommandError({ ...gitCommandContext(commandInput), detail: "Git command timed out.", }), - ), - ), - ); - }, - ); + ), + onSome: Effect.succeed, + }), + ), + ); + }); - const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) => - executeRaw(input).pipe( + const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) => { + const timeoutMs = resolveGitCommandTimeoutMs(input.args, input.timeoutMs); + return executeRaw(input, timeoutMs).pipe( withMetrics({ counter: gitCommandsTotal, timer: gitCommandDuration, @@ -971,7 +999,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, }), (execution) => - input.timeoutMs === null || (input.timeoutMs ?? DEFAULT_TIMEOUT_MS) > DEFAULT_TIMEOUT_MS + timeoutMs === null || timeoutMs > DEFAULT_TIMEOUT_MS ? execution : gitProcesses.withPermits(1)(execution), Effect.withSpan(input.operation, { @@ -983,6 +1011,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, }), ); + }; const executeGit = ( operation: string, @@ -1177,9 +1206,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const resolvedGitCommonDir = path.isAbsolute(commonDirOutput) ? path.normalize(commonDirOutput) : path.resolve(cwd, commonDirOutput); - const gitCommonDir = yield* fileSystem - .realPath(resolvedGitCommonDir) - .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir)); + const gitCommonDir = yield* canonicalizeExistingPath(fileSystem, resolvedGitCommonDir); const [worktreeRootResult, currentBranchResult] = yield* Effect.all( [ executeGit( @@ -1602,7 +1629,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetailsRemote.branch", cwd, - ["rev-parse", "--abbrev-ref", "HEAD"], + ["branch", "--show-current"], { allowNonZeroExit: true }, ).pipe( Effect.catchTags({ @@ -1624,7 +1651,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.statusDetailsRemote.branch", cwd, - args: ["rev-parse", "--abbrev-ref", "HEAD"], + args: ["branch", "--show-current"], }), detail: "Git branch lookup failed.", exitCode: branchResult.exitCode, @@ -1715,19 +1742,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* detail: "Git index is locked. Status will resume when the index lock is removed.", }); // Status can succeed while locked, repeatedly running LFS clean filters without caching. - if ( - yield* fileSystem.exists(lockPath).pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - ...lockError, - detail: "Failed to check the Git index lock.", - cause, - }), - ), - ) - ) { - return yield* lockError; + const indexIsLocked = fileSystem.exists(lockPath).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...lockError, + detail: "Failed to check the Git index lock.", + cause, + }), + ), + ); + if (yield* indexIsLocked) { + // Other status readers can briefly lock the index while refreshing its cache. + // Give them time to finish without running clean filters against a locked index. + yield* Effect.sleep("1 second"); + if (yield* indexIsLocked) return yield* lockError; } } const statusResult = yield* executeGitWithStableDiagnostics( @@ -2265,7 +2294,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* true, ).pipe(Effect.map((stdout) => stdout.trim())); yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { - timeoutMs: 30_000, fallbackErrorDetail: "git pull failed", }); const afterSha = yield* runGitStdout( @@ -3559,7 +3587,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ) : null; - const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); + const remoteNames = remoteExists + ? yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])) + : []; + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef( + input.refName, + remoteNames, + ); const localTrackedBranchTargetExists = remoteExists && localTrackedBranchCandidate ? yield* executeGit( @@ -3572,16 +3606,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ).pipe(Effect.map((result) => result.exitCode === 0)) : false; + const availableLocalTrackingBranch = + remoteExists && !localTrackingBranch && localTrackedBranchCandidate + ? localTrackedBranchTargetExists + ? yield* resolveAvailableBranchName(input.cwd, localTrackedBranchCandidate) + : localTrackedBranchCandidate + : null; const checkoutArgs = localInputExists ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists - ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch - ? ["checkout", "--track", input.refName] - : remoteExists && localTrackingBranch - ? ["checkout", localTrackingBranch] - : ["checkout", input.refName]; + : remoteExists && !localTrackingBranch && availableLocalTrackingBranch + ? ["checkout", "--track", "-b", availableLocalTrackingBranch, input.refName] + : remoteExists && localTrackingBranch + ? ["checkout", localTrackingBranch] + : ["checkout", input.refName]; // A stale ref must not turn into a path checkout that discards local edits. yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, [...checkoutArgs, "--"], { @@ -3639,16 +3677,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); + const invalidateRefs: GitVcsDriver.GitVcsDriver["Service"]["invalidateRefs"] = (cwd) => + invalidateListRefsSnapshot(cwd).pipe(Effect.ignore); const withListRefsInvalidation = ( cwd: string, effect: Effect.Effect, ): Effect.Effect => effect.pipe( Effect.ensuring( - Effect.all([ - invalidateListRefsSnapshot(cwd).pipe(Effect.ignore), - invalidateStatusStaticCaches(cwd).pipe(Effect.ignore), - ]), + Effect.all([invalidateRefs(cwd), invalidateStatusStaticCaches(cwd).pipe(Effect.ignore)]), ), ); const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( @@ -3660,7 +3697,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const cacheKey = normalizeRepositoryPathsCacheKey(input.cwd); yield* Cache.invalidate(repositoryPathsRefreshCache, cacheKey); yield* Cache.invalidate(repositoryPathsCache, cacheKey); - yield* invalidateListRefsSnapshot(input.cwd).pipe(Effect.ignore); + yield* invalidateRefs(input.cwd); }), ), ); @@ -3682,6 +3719,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* getReviewDiffFileContents, readConfigValue, listRefs, + invalidateRefs, createWorktree: (input, options) => withListRefsInvalidation(input.cwd, createWorktree(input, options)), fetchPullRequestBranch: (input) => diff --git a/apps/server/src/vcs/VcsCacheInterruption.test.ts b/apps/server/src/vcs/VcsCacheInterruption.test.ts new file mode 100644 index 000000000000..0c46eb8e4569 --- /dev/null +++ b/apps/server/src/vcs/VcsCacheInterruption.test.ts @@ -0,0 +1,41 @@ +import { assert, it } from "@effect/vitest"; +import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; + +// Status and ref refreshes share cached lookups. A cancelled lookup must notify +// every consumer so the panel can finish its refresh and retry. +it.effect("notifies every cache consumer when a shared lookup is interrupted", () => + Effect.gen(function* () { + const release = yield* Deferred.make(); + let attempts = 0; + const cache = yield* Cache.make({ + capacity: 1, + lookup: (_cwd: string) => + Effect.suspend(() => + ++attempts === 1 + ? Deferred.await(release).pipe(Effect.andThen(Effect.interrupt)) + : Effect.succeed(42), + ), + }); + const first = yield* Cache.get(cache, "repo").pipe( + Effect.exit, + Effect.forkChild({ startImmediately: true }), + ); + const second = yield* Cache.get(cache, "repo").pipe( + Effect.exit, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(release, undefined); + for (const consumer of [first, second]) { + const exit = yield* Fiber.join(consumer); + assert.isTrue(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)); + } + assert.equal(attempts, 1); + assert.equal(yield* Cache.get(cache, "repo"), 42); + assert.equal(attempts, 2); + }), +); diff --git a/apps/server/src/vcs/VcsLocalWatch.ts b/apps/server/src/vcs/VcsLocalWatch.ts new file mode 100644 index 000000000000..ea5142081c4c --- /dev/null +++ b/apps/server/src/vcs/VcsLocalWatch.ts @@ -0,0 +1,33 @@ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import type * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; + +const VCS_STATUS_WATCH_IGNORED_ROOTS = new Set([".git"]); + +export function watchEventPath(path: Path.Path, rawCwd: string, eventPath: string): string | null { + const relativePath = path.isAbsolute(eventPath) ? path.relative(rawCwd, eventPath) : eventPath; + if (!relativePath || relativePath === ".") return null; + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return null; + return relativePath.split(path.sep).join("/"); +} + +export function shouldIgnoreWatchEventPath(relativePath: string): boolean { + const [rootSegment] = relativePath.split("/"); + return rootSegment ? VCS_STATUS_WATCH_IGNORED_ROOTS.has(rootSegment) : false; +} + +export function localWatchRefreshSignals( + relativePaths: Stream.Stream, + shouldRefreshForPaths: (relativePaths: readonly string[]) => Effect.Effect, + debounceDuration: Duration.Duration = Duration.millis(150), +): Stream.Stream { + return relativePaths.pipe( + Stream.filter((relativePath) => !shouldIgnoreWatchEventPath(relativePath)), + Stream.groupedWithin(512, debounceDuration), + Stream.map((paths) => [...new Set(paths)]), + Stream.filter((paths) => paths.length > 0), + Stream.filterEffect(shouldRefreshForPaths), + Stream.map(() => undefined), + ); +} diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index ac21a61ddf78..b244b6469c16 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -12,9 +12,11 @@ import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; 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 Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; import type { BackgroundScope, VcsStatusLocalResult, @@ -26,6 +28,7 @@ import { GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as VcsProcess from "./VcsProcess.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; @@ -145,6 +148,57 @@ function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope) } describe("VcsStatusBroadcaster", () => { + it("ignores Git internal watcher paths", () => { + assert.isTrue(VcsStatusBroadcaster.shouldIgnoreWatchEventPath(".git/FETCH_HEAD")); + assert.isTrue(VcsStatusBroadcaster.shouldIgnoreWatchEventPath(".git/logs/HEAD")); + assert.isFalse(VcsStatusBroadcaster.shouldIgnoreWatchEventPath("src/.gitkeep")); + assert.isFalse(VcsStatusBroadcaster.shouldIgnoreWatchEventPath("src/app.ts")); + }); + + it.effect("batches watcher refresh decisions after ignored roots are filtered", () => + Effect.gen(function* () { + const checkedBatches: string[][] = []; + const refreshes = Array.from( + yield* Stream.runCollect( + VcsStatusBroadcaster.localWatchRefreshSignals( + Stream.make("src/app.ts", "dist/app.js"), + (relativePaths) => + Effect.sync(() => { + checkedBatches.push([...relativePaths]); + return relativePaths.some((relativePath) => relativePath !== "dist/app.js"); + }), + Duration.millis(1), + ), + ).pipe(Effect.timeout("2 seconds")), + ); + + assert.deepStrictEqual(checkedBatches, [["src/app.ts", "dist/app.js"]]); + assert.equal(refreshes.length, 1); + }), + ); + + it.effect("does not refresh when every debounced watcher path is ignored", () => + Effect.gen(function* () { + const checkedBatches: string[][] = []; + const refreshes = Array.from( + yield* Stream.runCollect( + VcsStatusBroadcaster.localWatchRefreshSignals( + Stream.make(".git/FETCH_HEAD", "dist/app.js", "dist/app.css"), + (relativePaths) => + Effect.sync(() => { + checkedBatches.push([...relativePaths]); + return false; + }), + Duration.millis(1), + ), + ).pipe(Effect.timeout("2 seconds")), + ); + + assert.deepStrictEqual(checkedBatches, [["dist/app.js", "dist/app.css"]]); + assert.deepStrictEqual(refreshes, []); + }), + ); + it.effect.skipIf(!symlinksSupported)( "automatically pulls an enabled clean default branch when status detects it is behind", () => { @@ -206,6 +260,250 @@ describe("VcsStatusBroadcaster", () => { }, ); + it.effect("shares sibling watchers and releases each refresh destination independently", () => + Effect.gen(function* () { + const root = "/repo"; + const sibling = "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/repo.worktrees/feature"; + const events = yield* Queue.unbounded(); + const refreshed = yield* Queue.unbounded(); + const watches: string[] = []; + const closed: string[] = []; + let ignoreChecks = 0; + const fs = yield* FileSystem.FileSystem; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provide( + Layer.succeed(FileSystem.FileSystem, { + ...fs, + exists: () => Effect.succeed(true), + realPath: (cwd) => Effect.succeed(cwd), + watch: (cwd) => + Stream.unwrap( + Effect.sync(() => { + watches.push(cwd); + return (cwd === sibling ? Stream.fromQueue(events) : Stream.never).pipe( + Stream.ensuring( + Effect.sync(() => { + closed.push(cwd); + }), + ), + ); + }), + ), + }), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => false)), + Layer.provide( + Layer.succeed(VcsProcess.VcsProcess, { + run: (input) => + Effect.sync(() => { + if (input.operation !== "VcsStatusBroadcaster.worktrees") ignoreChecks++; + return { + exitCode: ChildProcessSpawner.ExitCode( + input.operation === "VcsStatusBroadcaster.worktrees" ? 0 : 1, + ), + stdout: + input.operation === "VcsStatusBroadcaster.worktrees" + ? `worktree ${root}\n\nworktree ${sibling}\n` + : "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(baseLocalStatus), + remoteStatus: () => Effect.succeed(baseRemoteStatus), + invalidateLocalStatus: (cwd) => Queue.offer(refreshed, cwd).pipe(Effect.asVoid), + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + }), + ), + ); + yield* Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const start = Effect.fnUntraced(function* (cwd: string) { + const ready = yield* Deferred.make(); + const fiber = yield* broadcaster.streamStatus({ cwd }).pipe( + Stream.runForEach(() => Deferred.succeed(ready, undefined).pipe(Effect.ignore)), + Effect.forkChild, + ); + yield* Deferred.await(ready); + return fiber; + }); + const rootStream = yield* start(root); + const siblingStream = yield* start(sibling); + assert.deepStrictEqual(watches.sort(), [root, sibling].sort()); + yield* Queue.offer(events, { _tag: "Update", path: "file.ts" }); + yield* TestClock.adjust("150 millis"); + assert.deepStrictEqual( + [yield* Queue.take(refreshed), yield* Queue.take(refreshed)].sort(), + [root, sibling].sort(), + ); + assert.equal(ignoreChecks, 1); + yield* Fiber.interrupt(rootStream); + assert.deepStrictEqual(closed, []); + yield* Queue.offer(events, { _tag: "Update", path: "file.ts" }); + yield* TestClock.adjust("150 millis"); + assert.equal(yield* Queue.take(refreshed), sibling); + assert.equal(yield* Queue.size(refreshed), 0); + assert.equal(ignoreChecks, 2); + yield* Fiber.interrupt(siblingStream); + assert.deepStrictEqual(closed.sort(), [root, sibling].sort()); + }).pipe(Effect.provide(testLayer)); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live( + "keeps panel-demand polling, automatic pull, and sibling-worktree refreshes active together", + () => { + const localStatus: VcsStatusLocalResult = { + ...baseLocalStatus, + isDefaultRef: true, + refName: "main", + }; + const policyScopes: BackgroundScope[] = []; + const autoPullCwds: string[] = []; + const watcherCwds: string[] = []; + let rootDir = ""; + let siblingDir = ""; + let remoteStatusCalls = 0; + let localInvalidationCalls = 0; + let pullCalls = 0; + let periodicRemoteDeferred: Deferred.Deferred | null = null; + const fakeFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return { + ...fs, + exists: () => Effect.succeed(true), + realPath: (cwd: string) => Effect.succeed(cwd), + watch: (cwd: string) => + cwd === siblingDir + ? Stream.make({ _tag: "Update" as const, path: "sibling-change.txt" }) + : Stream.never, + }; + }), + ).pipe(Layer.provide(NodeServices.layer)); + const vcsProcessLayer = Layer.succeed(VcsProcess.VcsProcess, { + run: (input) => { + if (input.operation === "VcsStatusBroadcaster.worktrees") { + return Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: `worktree ${rootDir}\n\nworktree ${siblingDir}\n`, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + } + watcherCwds.push(input.cwd); + return Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + }, + }); + + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provide(fakeFileSystemLayer), + Layer.provideMerge(NodeServices.layer), + Layer.provide(vcsProcessLayer), + Layer.provide( + makeBackgroundPolicyLayer((scope) => { + policyScopes.push(scope); + return true; + }), + ), + Layer.provide( + Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { + isEnabled: (cwd) => + Effect.sync(() => { + autoPullCwds.push(cwd); + return cwd === rootDir; + }), + }), + ), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(localStatus), + remoteStatus: () => + Effect.gen(function* () { + remoteStatusCalls += 1; + if (remoteStatusCalls >= 3 && periodicRemoteDeferred) { + yield* Deferred.succeed(periodicRemoteDeferred, undefined).pipe(Effect.ignore); + } + return { + ...baseRemoteStatus, + behindCount: pullCalls === 0 ? 1 : 0, + }; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + pullCurrentBranch: () => + Effect.sync(() => { + pullCalls += 1; + return { + status: "pulled" as const, + refName: "main", + upstreamRef: "origin/main", + }; + }), + }), + ), + ); + + return Effect.gen(function* () { + rootDir = "/repo"; + siblingDir = "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/repo.worktrees/feature"; + periodicRemoteDeferred = yield* Deferred.make(); + const pulledSnapshotDeferred = yield* Deferred.make(); + const siblingRefreshDeferred = yield* Deferred.make(); + const streamScope = yield* Scope.make(); + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: rootDir }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.millis(25)) }, + ), + (event) => { + if (event._tag === "snapshot" && event.remote?.behindCount === 0) { + return Deferred.succeed(pulledSnapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "localUpdated") { + return Deferred.succeed(siblingRefreshDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }, + ).pipe(Effect.forkIn(streamScope)); + + yield* Deferred.await(pulledSnapshotDeferred).pipe(Effect.timeout("2 seconds")); + yield* Deferred.await(siblingRefreshDeferred).pipe(Effect.timeout("2 seconds")); + yield* Deferred.await(periodicRemoteDeferred).pipe(Effect.timeout("2 seconds")); + + assert.equal(pullCalls, 1); + assert.isAtLeast(localInvalidationCalls, 2); + assert.isAtLeast(autoPullCwds.length, 1); + assert.isTrue(autoPullCwds.every((cwd) => cwd === rootDir)); + assert.includeDeepMembers(policyScopes, [{ type: "vcs-status", cwd: rootDir }]); + assert.include(watcherCwds, siblingDir); + + yield* Scope.close(streamScope, Exit.void); + }).pipe(Effect.provide(testLayer)); + }, + ); + it.effect("reuses the cached VCS status across repeated reads", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -382,6 +680,7 @@ describe("VcsStatusBroadcaster", () => { remoteStatusCalls: 0, localInvalidationCalls: 0, remoteInvalidationCalls: 0, + remoteStatusRefreshUpstreamValues: [] as Array, }; return Effect.gen(function* () { @@ -396,7 +695,7 @@ describe("VcsStatusBroadcaster", () => { ...baseRemoteStatus, aheadCount: 2, }; - const refreshed = yield* broadcaster.refreshStatus("/repo"); + const refreshed = yield* broadcaster.refreshStatus("/repo", { refreshUpstream: false }); const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); assert.deepStrictEqual(initial, baseStatus); @@ -410,6 +709,7 @@ describe("VcsStatusBroadcaster", () => { }); assert.equal(state.localStatusCalls, 2); assert.equal(state.remoteStatusCalls, 2); + assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [undefined, false]); assert.equal(state.localInvalidationCalls, 1); assert.equal(state.remoteInvalidationCalls, 1); }).pipe(Effect.provide(makeTestLayer(state))); @@ -612,9 +912,10 @@ describe("VcsStatusBroadcaster", () => { return Effect.void; }).pipe(Effect.forkScoped); - const snapshot = yield* Deferred.await(snapshotDeferred); - yield* broadcaster.refreshStatus("/repo"); - const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); + const snapshot = yield* Deferred.await(snapshotDeferred).pipe(Effect.timeout("2 seconds")); + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred).pipe( + Effect.timeout("2 seconds"), + ); assert.deepStrictEqual(snapshot, { _tag: "snapshot", @@ -628,6 +929,131 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("publishes explicit local updates even 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(); + const localUpdatedDeferred = yield* Deferred.make(); + + 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); + + yield* Deferred.await(snapshotDeferred).pipe(Effect.timeout("2 seconds")); + yield* broadcaster.refreshLocalStatus("/repo"); + const localUpdated = yield* Deferred.await(localUpdatedDeferred).pipe( + Effect.timeout("2 seconds"), + ); + + assert.deepStrictEqual(localUpdated, { + _tag: "localUpdated", + local: baseLocalStatus, + } satisfies VcsStatusStreamEvent); + assert.isAtLeast(state.localStatusCalls, 2); + assert.isAtLeast(state.localInvalidationCalls, 1); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it("parses worktree paths from porcelain output", () => { + assert.deepStrictEqual( + VcsStatusBroadcaster.parseWorktreePaths( + [ + "worktree /repo", + "HEAD abc", + "branch refs/heads/main", + "", + "worktree /repo.worktrees/feature", + "HEAD def", + "branch refs/heads/feature/source-control", + "", + ].join("\n"), + ), + ["/repo", "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/repo.worktrees/feature"], + ); + }); + + it.effect("skips missing sibling worktree paths before retaining watchers", () => { + const rootDir = process.cwd(); + const siblingDir = `${rootDir}/..`; + const missingDir = `${rootDir}/.missing-worktree-for-vcs-status-test`; + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const snapshotDeferred = yield* Deferred.make(); + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: rootDir }), (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkScoped); + + const snapshot = yield* Deferred.await(snapshotDeferred).pipe(Effect.timeout("2 seconds")); + + assert.deepStrictEqual(snapshot, { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + } satisfies VcsStatusStreamEvent); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 0); + assert.isFalse(yield* fileSystem.exists(missingDir)); + }).pipe( + Effect.provide( + Layer.merge( + makeTestLayer(state), + Layer.succeed(VcsProcess.VcsProcess, { + run: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: [ + `worktree ${rootDir}`, + "HEAD abc", + "branch refs/heads/main", + "", + `worktree ${siblingDir}`, + "HEAD def", + "branch refs/heads/feature/live", + "", + `worktree ${missingDir}`, + "HEAD ghi", + "branch refs/heads/feature/missing", + "", + ].join("\n"), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + ), + ); + }); + it.effect("loads remote status once when periodic refreshes are disabled", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index ae9abdd0cc4e..27d7631de6d0 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -6,6 +6,8 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; 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 PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; @@ -28,6 +30,10 @@ import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { localWatchRefreshSignals, watchEventPath } from "./VcsLocalWatch.ts"; +import * as VcsProcess from "./VcsProcess.ts"; + +export { localWatchRefreshSignals, shouldIgnoreWatchEventPath } from "./VcsLocalWatch.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); @@ -139,10 +145,22 @@ interface ActiveRemotePoller { readonly demandCwds: Ref.Ref>; } +interface ActiveLocalWatcher { + readonly fiber: Fiber.Fiber; + readonly refreshCwds: ReadonlyMap; +} + interface StreamStatusOptions { readonly automaticRemoteRefreshInterval?: Effect.Effect; } +export function parseWorktreePaths(output: string): readonly string[] { + return output + .split(/\r?\n/u) + .flatMap((line) => (line.startsWith("worktree ") ? [line.slice("worktree ".length)] : [])) + .filter((worktreePath) => worktreePath.length > 0); +} + export class VcsAutoPullPolicy extends Context.Reference<{ readonly isEnabled: (cwd: string) => Effect.Effect; }>("t3/vcs/VcsAutoPullPolicy", { @@ -191,7 +209,10 @@ export class VcsStatusBroadcaster extends Context.Service< readonly refreshLocalStatus: ( cwd: string, ) => Effect.Effect; - readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly refreshStatus: ( + cwd: string, + options?: { readonly refreshUpstream?: boolean }, + ) => Effect.Effect; /** * Refresh a loaded cwd after a turn if background policy allows it. * GitManager retries missing PRs for the current branch and keeps known @@ -223,6 +244,8 @@ export const make = Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const vcsProcess = yield* Effect.serviceOption(VcsProcess.VcsProcess); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), (pubsub) => PubSub.shutdown(pubsub), @@ -244,6 +267,7 @@ export const make = Effect.gen(function* () { return lock.withPermits(1)(effect); }; const pollersRef = yield* SynchronizedRef.make(new Map()); + const watchersRef = yield* SynchronizedRef.make(new Map()); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( cwd: string, @@ -252,7 +276,11 @@ export const make = Effect.gen(function* () { }); const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( - function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { + function* ( + cwd: string, + local: VcsStatusLocalResult, + options?: { publish?: boolean; forcePublish?: boolean }, + ) { const nextLocal = { fingerprint: fingerprintStatusPart(local), value: local, @@ -264,7 +292,10 @@ export const make = Effect.gen(function* () { ...previous, local: nextLocal, }); - return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + return [ + options?.forcePublish === true || previous.local?.fingerprint !== nextLocal.fingerprint, + nextCache, + ] as const; }); if (options?.publish && shouldPublish) { @@ -397,10 +428,13 @@ export const make = Effect.gen(function* () { }); const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( - function* (cwd: string) { + function* (cwd: string, options?: { forcePublish?: boolean }) { yield* workflow.invalidateLocalStatus(cwd); const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + return yield* updateCachedLocalStatus(cwd, local, { + publish: true, + ...(options?.forcePublish === true ? { forcePublish: true } : {}), + }); }, ); @@ -408,7 +442,7 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - return yield* refreshLocalStatusCore(cwd); + return yield* refreshLocalStatusCore(cwd, { forcePublish: true }); }); const maybeAutoPull = Effect.fn("VcsStatusBroadcaster.maybeAutoPull")(function* ( @@ -472,7 +506,7 @@ export const make = Effect.gen(function* () { const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", - )(function* (rawCwd) { + )(function* (rawCwd, options) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); // invalidateStatus (not the two partial invalidations) so an explicit // refresh also bypasses GitManager's slow PR-lookup cache. @@ -481,7 +515,7 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { yield* workflow.invalidateStatus(cwd); const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd }, options)], { concurrency: "unbounded" }, ); const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); @@ -694,11 +728,149 @@ export const make = Effect.gen(function* () { } }); + const worktreeWatchPaths = Effect.fn("VcsStatusBroadcaster.worktreeWatchPaths")(function* ( + cwd: string, + ) { + if (Option.isNone(vcsProcess)) return []; + const result = yield* vcsProcess.value + .run({ + operation: "VcsStatusBroadcaster.worktrees", + command: "git", + args: ["worktree", "list", "--porcelain"], + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 1_000_000, + }) + .pipe(Effect.orElseSucceed(() => null)); + if (result === null || result.exitCode !== 0) return []; + const rootPath = path.resolve(cwd); + const candidatePaths = parseWorktreePaths(result.stdout).filter( + (worktreePath) => path.resolve(worktreePath) !== rootPath, + ); + const existingPaths = yield* Effect.forEach(candidatePaths, (worktreePath) => + fs.exists(worktreePath).pipe( + Effect.orElseSucceed(() => false), + Effect.map((exists) => (exists ? worktreePath : null)), + ), + ); + return existingPaths.filter((worktreePath): worktreePath is string => worktreePath !== null); + }); + + const makeLocalWatchLoop = (watchCwd: string) => + localWatchRefreshSignals( + fs.watch(watchCwd).pipe( + Stream.map((event) => watchEventPath(path, watchCwd, event.path)), + Stream.filter((relativePath): relativePath is string => relativePath !== null), + ), + (relativePaths) => + Option.match(vcsProcess, { + onNone: () => Effect.succeed(true), + onSome: (process) => + process + .run({ + operation: "VcsStatusBroadcaster.watch.checkIgnore", + command: "git", + args: ["check-ignore", "-z", "--stdin"], + cwd: watchCwd, + stdin: `${relativePaths.join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 1_000_000, + }) + .pipe( + Effect.map((result) => { + if (result.exitCode !== 0) return true; + const ignoredPaths = new Set( + result.stdout.split("\0").filter((ignoredPath) => ignoredPath.length > 0), + ); + return relativePaths.some((relativePath) => !ignoredPaths.has(relativePath)); + }), + Effect.orElseSucceed(() => true), + ), + }), + ).pipe( + Stream.runForEach(() => + Effect.gen(function* () { + const watchers = yield* SynchronizedRef.get(watchersRef); + const refreshCwds = watchers.get(watchCwd)?.refreshCwds; + if (!refreshCwds) return; + yield* Effect.forEach( + refreshCwds.keys(), + (refreshCwd) => + refreshLocalStatusCore(refreshCwd, { forcePublish: true }).pipe( + Effect.ignoreCause({ log: true }), + ), + { concurrency: "unbounded", discard: true }, + ); + }), + ), + Effect.ignoreCause({ log: true }), + ); + + const retainLocalWatcher = Effect.fn("VcsStatusBroadcaster.retainLocalWatcher")(function* ( + watchCwd: string, + refreshCwd: string, + ) { + yield* SynchronizedRef.modifyEffect(watchersRef, (activeWatchers) => { + const existing = activeWatchers.get(watchCwd); + const refreshCwds = new Map(existing?.refreshCwds); + refreshCwds.set(refreshCwd, (refreshCwds.get(refreshCwd) ?? 0) + 1); + const fiber = + existing && existing.fiber.pollUnsafe() === undefined + ? Effect.succeed(existing.fiber) + : makeLocalWatchLoop(watchCwd).pipe(Effect.forkIn(broadcasterScope)); + return fiber.pipe( + Effect.map((fiber) => { + const nextWatchers = new Map(activeWatchers); + nextWatchers.set(watchCwd, { fiber, refreshCwds }); + return [undefined, nextWatchers] as const; + }), + ); + }); + }); + + const releaseLocalWatcher = Effect.fn("VcsStatusBroadcaster.releaseLocalWatcher")(function* ( + watchCwd: string, + refreshCwd: string, + ) { + const watcherToInterrupt = yield* SynchronizedRef.modify(watchersRef, (activeWatchers) => { + const existing = activeWatchers.get(watchCwd); + if (!existing) return [null, activeWatchers] as const; + const refreshCwds = new Map(existing.refreshCwds); + const count = refreshCwds.get(refreshCwd) ?? 0; + if (count > 1) refreshCwds.set(refreshCwd, count - 1); + else refreshCwds.delete(refreshCwd); + const nextWatchers = new Map(activeWatchers); + if (refreshCwds.size > 0) { + nextWatchers.set(watchCwd, { ...existing, refreshCwds }); + return [null, nextWatchers] as const; + } + nextWatchers.delete(watchCwd); + return [existing.fiber, nextWatchers] as const; + }); + + if (watcherToInterrupt) { + yield* Fiber.interrupt(watcherToInterrupt).pipe(Effect.ignore); + } + }); + const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) => Stream.unwrap( Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); const subscription = yield* PubSub.subscribe(changesPubSub); + const siblingWatchCwds = yield* worktreeWatchPaths(cwd); + const watchedCwds = [cwd, ...siblingWatchCwds]; + yield* Effect.forEach( + watchedCwds, + (watchCwd) => + Effect.acquireRelease(retainLocalWatcher(watchCwd, cwd), () => + releaseLocalWatcher(watchCwd, cwd), + ), + { discard: true }, + ); + yield* Effect.yieldNow; const initialLocal = yield* getOrLoadLocalStatus(cwd); const cachedStatus = yield* getCachedStatus(cwd); const initialRemote = cachedStatus?.remote?.value ?? null; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index bcc19be00da5..fb5257ee5849 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -83,7 +83,10 @@ import { worktreeSetupActivityId, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; -import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { + getBackgroundActivityPresetSettings, + resolveServerBackgroundActivitySettings, +} from "@t3tools/shared/backgroundActivitySettings"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -168,6 +171,8 @@ import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; +import { panelFetchOverridesHostLock } from "./sourceControl/SourceControlPanelFetchPolicy.ts"; +import * as SourceControlPanelService from "./sourceControl/SourceControlPanelService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; @@ -664,6 +669,7 @@ const makeWsRpcLayer = ( ); const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const sourceControlPanel = yield* SourceControlPanelService.SourceControlPanelService; const pullRequests = yield* PullRequestService.PullRequestService; const withPullRequestViewer = pullRequests.withRoutingCredential; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; @@ -2131,9 +2137,9 @@ const makeWsRpcLayer = ( }; }); - const refreshGitStatus = (cwd: string) => + const refreshGitStatus = (cwd: string, options?: { readonly refreshUpstream?: boolean }) => vcsStatusBroadcaster - .refreshStatus(cwd) + .refreshStatus(cwd, options) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); return WsRpcGroup.of({ @@ -3605,6 +3611,251 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "git" }, ), + [WS_METHODS.vcsPanelSnapshot]: (input) => + observeRpcEffect(WS_METHODS.vcsPanelSnapshot, sourceControlPanel.snapshot(input), { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.vcsPanelBranchDetails]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelBranchDetails, + sourceControlPanel.branchDetails(input), + { + "rpc.aggregate": "vcs", + }, + ), + [WS_METHODS.vcsPanelCommitFiles]: (input) => + observeRpcEffect(WS_METHODS.vcsPanelCommitFiles, sourceControlPanel.commitFiles(input), { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.vcsPanelBranchCommits]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelBranchCommits, + sourceControlPanel.branchCommits(input), + { + "rpc.aggregate": "vcs", + }, + ), + [WS_METHODS.vcsPanelStashDetails]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelStashDetails, + sourceControlPanel.stashDetails(input), + { + "rpc.aggregate": "vcs", + }, + ), + [WS_METHODS.vcsPanelEnrichWorkingTreeFiles]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelEnrichWorkingTreeFiles, + sourceControlPanel.enrichWorkingTreeFiles(input), + { + "rpc.aggregate": "vcs", + }, + ), + [WS_METHODS.vcsPanelReadFileDiff]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelReadFileDiff, + sourceControlPanel.readFileDiff(input), + { + "rpc.aggregate": "vcs", + }, + ), + [WS_METHODS.vcsPanelCompare]: (input) => + observeRpcEffect(WS_METHODS.vcsPanelCompare, sourceControlPanel.compare(input), { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.vcsPanelCommitStaged]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelCommitStaged, + sourceControlPanel + .commitStaged(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelStageFiles]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelStageFiles, + sourceControlPanel + .stageFiles(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelUnstageFiles]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelUnstageFiles, + sourceControlPanel + .unstageFiles(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelDiscardFiles]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelDiscardFiles, + sourceControlPanel + .discardFiles(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelPullBranch]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelPullBranch, + sourceControlPanel + .pullBranch(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelPushBranch]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelPushBranch, + sourceControlPanel + .pushBranch(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelDeleteBranch]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelDeleteBranch, + sourceControlPanel + .deleteBranch(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelUndoLatestCommit]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelUndoLatestCommit, + sourceControlPanel + .undoLatestCommit(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelRevertCommit]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelRevertCommit, + sourceControlPanel + .revertCommit(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelCheckoutCommit]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelCheckoutCommit, + sourceControlPanel + .checkoutCommit(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelCreateBranchFromCommit]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelCreateBranchFromCommit, + sourceControlPanel + .createBranchFromCommit(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelMergeBranchIntoCurrent]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelMergeBranchIntoCurrent, + sourceControlPanel + .mergeBranchIntoCurrent(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelRebaseCurrentOnto]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelRebaseCurrentOnto, + sourceControlPanel + .rebaseCurrentOnto(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelFetchBranch]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelFetchBranch, + sourceControlPanel + .fetchBranch(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd, { refreshUpstream: false }))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelFetchRemote]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelFetchRemote, + sourceControlPanel + .fetchRemote(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd, { refreshUpstream: false }))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelFetchAllRemotes]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelFetchAllRemotes, + Effect.gen(function* () { + if (input.force !== true) { + const shouldRun = yield* Effect.all([ + backgroundPolicy.shouldRunScopeWork({ type: "git-refs", cwd: input.cwd }), + backgroundPolicy.shouldRunScopeWork({ type: "vcs-status", cwd: input.cwd }), + ]).pipe(Effect.map((decisions) => decisions.some(Boolean))); + if ( + !shouldRun && + !panelFetchOverridesHostLock({ + policy: yield* backgroundPolicy.snapshot, + settings: yield* serverSettings.getSettings.pipe( + Effect.map(resolveServerBackgroundActivitySettings), + Effect.orElseSucceed(() => getBackgroundActivityPresetSettings("balanced")), + ), + cwd: input.cwd, + }) + ) { + return false; + } + } + const fetched = yield* sourceControlPanel.fetchAllRemotes(input); + if (fetched) { + yield* refreshGitStatus(input.cwd, { refreshUpstream: false }); + } + return fetched; + }), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelAddRemote]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelAddRemote, + sourceControlPanel.addRemote(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelRemoveRemote]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelRemoveRemote, + sourceControlPanel + .removeRemote(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelCreateStash]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelCreateStash, + sourceControlPanel + .createStash(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelApplyStash]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelApplyStash, + sourceControlPanel + .applyStash(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelPopStash]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelPopStash, + sourceControlPanel.popStash(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelDropStash]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelDropStash, + sourceControlPanel.dropStash(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.gitRunStackedAction]: (input) => observeRpcStream( WS_METHODS.gitRunStackedAction, diff --git a/apps/web/package.json b/apps/web/package.json index 587faeb2ac02..860a10ca9ba5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,6 +31,7 @@ "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", + "@tanstack/react-virtual": "^3.14.11", "@tiptap/core": "^3.31.3", "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c28bb4752811..08efbe432ec1 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -8,7 +8,11 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; -import { LegendList, type LegendListRef } from "@legendapp/list/react"; +import { + LegendList, + type LegendListRef, + type LegendListRenderItemProps, +} from "@legendapp/list/react"; import { ChevronDownIcon, GitBranchIcon } from "lucide-react"; import { useCallback, @@ -864,15 +868,17 @@ export function BranchToolbarBranchSelector({ ref={branchListRef} data={filteredBranchPickerItems} - keyExtractor={(item) => item} - getItemType={(item) => + keyExtractor={(item: string) => item} + getItemType={(item: string) => item === checkoutPullRequestItemValue ? "checkout-pull-request" : item === createBranchItemValue ? "create-branch" : "branch" } - renderItem={({ item, index }) => renderPickerItem(item, index)} + renderItem={({ item, index }: LegendListRenderItemProps) => + renderPickerItem(item, index) + } estimatedItemSize={28} drawDistance={336} onLayout={() => { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a2708de8bda3..391b1468dc78 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -23,6 +23,7 @@ import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifa import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type RightPanelSurface, + fileSurfaceId, pullRequestSurface, selectActiveRightPanelSurface, useRightPanelStore, @@ -39,6 +40,7 @@ import { buildLoadingThreadFromShell, buildRunningThreadTurnInterruptInput, buildThreadTurnInterruptInput, + clearThreadErrorRecord, createLocalDispatchSnapshot, deriveComposerSendState, deriveLockedProvider, @@ -50,9 +52,11 @@ import { hasServerAcknowledgedLocalDispatch, shouldRefocusComposerOnWindowFocus, isBranchMismatchDismissedForSession, + isLatestRequestSequence, reconcileMountedTerminalThreadIds, recallCheckoutIsRepo, rememberCheckoutIsRepo, + retainThreadKeyRecord, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, restorePlanFollowUpComposer, @@ -74,6 +78,7 @@ import { resolveThreadSwitchTimeline, threadKeysShareEnvironment, timelineHasEphemeralPreviewUrls, + shouldApplySourceControlMetadataUpdateResult, scheduleEnvironmentReconnectWarning, startNewThreadForProject, codexArtifactTemplatePromptToAppend, @@ -302,7 +307,7 @@ describe("proactive panels", () => { ).toBe(false); expect( selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref)?.id, - ).toBe("file:src/second.ts"); + ).toBe(fileSurfaceId("src/second.ts")); }, ); @@ -1782,6 +1787,87 @@ describe("shouldWriteThreadErrorToCurrentServerThread", () => { }); }); +describe("clearThreadErrorRecord", () => { + it("clears only the selected thread error", () => { + expect( + clearThreadErrorRecord( + { + "environment-local:thread-1": "metadata failed", + "environment-local:thread-2": "send failed", + }, + "environment-local:thread-1", + ), + ).toEqual({ + "environment-local:thread-1": null, + "environment-local:thread-2": "send failed", + }); + }); + + it("keeps the same object when the selected thread has no error", () => { + const existing = { + "environment-local:thread-1": null, + "environment-local:thread-2": "send failed", + }; + + expect(clearThreadErrorRecord(existing, "environment-local:thread-1")).toBe(existing); + expect(clearThreadErrorRecord(existing, "environment-local:thread-3")).toBe(existing); + }); +}); + +describe("retainThreadKeyRecord", () => { + it("drops stale thread keys", () => { + expect( + retainThreadKeyRecord( + { + "environment-local:thread-1": "send failed", + "environment-local:thread-2": null, + }, + new Set(["environment-local:thread-1"]), + ), + ).toEqual({ + "environment-local:thread-1": "send failed", + }); + }); + + it("preserves reference identity when no keys are pruned", () => { + const existing = { + "environment-local:thread-1": "send failed", + }; + + expect(retainThreadKeyRecord(existing, new Set(["environment-local:thread-1"]))).toBe(existing); + }); +}); + +describe("shouldApplySourceControlMetadataUpdateResult", () => { + it("allows only the latest metadata update result for a thread", () => { + expect( + shouldApplySourceControlMetadataUpdateResult({ + currentSequence: 2, + requestSequence: 2, + }), + ).toBe(true); + expect( + shouldApplySourceControlMetadataUpdateResult({ + currentSequence: 2, + requestSequence: 1, + }), + ).toBe(false); + expect( + shouldApplySourceControlMetadataUpdateResult({ + currentSequence: undefined, + requestSequence: 1, + }), + ).toBe(false); + }); +}); + +describe("isLatestRequestSequence", () => { + it("rejects a stop completion after a newer thread starts another request", () => { + expect(isLatestRequestSequence({ currentSequence: 4, requestSequence: 3 })).toBe(false); + expect(isLatestRequestSequence({ currentSequence: 4, requestSequence: 4 })).toBe(true); + }); +}); + describe("startNewThreadForProject", () => { it("starts a thread through the supplied shared handler for the active project", () => { const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = []; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 528024de4393..4ab00d7f2491 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -527,6 +527,44 @@ export function shouldWriteThreadErrorToCurrentServerThread(input: { ); } +export function clearThreadErrorRecord( + existing: Record, + threadKey: string, +): Record { + if ((existing[threadKey] ?? null) === null) { + return existing; + } + return { + ...existing, + [threadKey]: null, + }; +} + +export function retainThreadKeyRecord( + existing: Record, + retainedThreadKeys: ReadonlySet, +): Record { + let changed = false; + const next: Record = {}; + for (const [threadKey, value] of Object.entries(existing)) { + if (retainedThreadKeys.has(threadKey)) { + next[threadKey] = value; + } else { + changed = true; + } + } + return changed ? next : existing; +} + +export function isLatestRequestSequence(input: { + readonly currentSequence: number | undefined; + readonly requestSequence: number; +}): boolean { + return input.currentSequence === input.requestSequence; +} + +export const shouldApplySourceControlMetadataUpdateResult = isLatestRequestSequence; + export function buildThreadTurnInterruptInput(thread: Pick): { threadId: ThreadId; turnId?: TurnId; diff --git a/apps/web/src/components/ChatView.sourceControl.test.ts b/apps/web/src/components/ChatView.sourceControl.test.ts new file mode 100644 index 000000000000..dd7369098a1f --- /dev/null +++ b/apps/web/src/components/ChatView.sourceControl.test.ts @@ -0,0 +1,1071 @@ +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { DraftId } from "../composerDraftStore"; +import { + selectThreadPreviewMiniPlayer, + usePreviewMiniPlayerStore, +} from "../previewMiniPlayerStore"; +import { + fileSurfaceId, + type RightPanelSurface, + selectActiveRightPanelSurface, + selectThreadRightPanelState, + useRightPanelStore, +} from "../rightPanelStore"; +import { retainThreadKeyRecord } from "./ChatView.logic"; +import { + buildSourceControlEnvironmentOption, + buildSourceControlProjectScriptPatch, + createSourceControlServerMetadataUpdateQueue, + isSourceControlAvailable, + normalizeSourceControlRightPanelPresence, + resolveSourceControlPanelTarget, + resolveThreadErrorDismissAction, + resolveThreadErrorPresentation, + resolveSourceControlDraftMetadataTarget, + retargetOpenSourceControlSurface, + runSourceControlServerMetadataUpdate, + selectSourceControlMetadataError, + sourceControlMetadataErrorFromFailure, +} from "./ChatView.sourceControl"; + +const environmentId = EnvironmentId.make("environment-local"); +const threadId = ThreadId.make("thread-1"); +const activeThreadRef = { environmentId, threadId }; +const metadata = { + branch: "feature/source-control", + worktreePath: "/tmp/source-control", +}; +const expectedBranch = "feature/previous-ref"; + +beforeEach(() => { + useRightPanelStore.setState({ byThreadKey: {} }); + usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); +}); + +describe("sourceControlMetadataErrorFromFailure", () => { + it("formats structured object errors without collapsing to object text", () => { + expect( + sourceControlMetadataErrorFromFailure({ + code: "ECONNRESET", + message: "metadata update failed", + }), + ).toBe("metadata update failed (ECONNRESET)"); + expect(sourceControlMetadataErrorFromFailure({ detail: "raw provider failure" })).toBe( + '{"detail":"raw provider failure"}', + ); + }); +}); + +describe("resolveSourceControlDraftMetadataTarget", () => { + it("prefers a draft id and falls back to the active thread ref", () => { + const draftId = DraftId.make("draft-1"); + + expect(resolveSourceControlDraftMetadataTarget({ activeThreadRef: null, draftId })).toBe( + draftId, + ); + expect(resolveSourceControlDraftMetadataTarget({ activeThreadRef, draftId: null })).toBe( + activeThreadRef, + ); + expect(resolveSourceControlDraftMetadataTarget({ activeThreadRef: null, draftId: null })).toBe( + null, + ); + }); +}); + +describe("resolveThreadErrorPresentation", () => { + it("keeps lower-priority failures available when every source has the same message", () => { + const repeatedMessage = "transport failed"; + const input = { + isServerThread: true, + localDraftError: null, + localServerError: repeatedMessage, + sessionError: repeatedMessage, + sourceControlMetadataError: repeatedMessage, + } as const; + + expect(resolveThreadErrorPresentation(input)).toEqual({ + error: repeatedMessage, + source: "local-server", + }); + expect(resolveThreadErrorPresentation({ ...input, localServerError: null })).toEqual({ + error: repeatedMessage, + source: "source-control", + }); + expect( + resolveThreadErrorPresentation({ + ...input, + localServerError: null, + sourceControlMetadataError: null, + }), + ).toEqual({ error: repeatedMessage, source: "session" }); + }); + + it("keeps draft errors independent from server-only error sources", () => { + expect( + resolveThreadErrorPresentation({ + isServerThread: false, + localDraftError: "draft failed", + localServerError: "ignored server error", + sessionError: "ignored session error", + sourceControlMetadataError: "ignored metadata error", + }), + ).toEqual({ error: "draft failed", source: "draft" }); + }); + + it("dismisses only the error source currently presented", () => { + expect(resolveThreadErrorDismissAction("draft")).toBe("clear-thread"); + expect(resolveThreadErrorDismissAction("local-server")).toBe("clear-thread"); + expect(resolveThreadErrorDismissAction("source-control")).toBe("clear-source-control"); + expect(resolveThreadErrorDismissAction("session")).toBe("mask-only"); + expect(resolveThreadErrorDismissAction(null)).toBe("mask-only"); + }); +}); + +describe("buildSourceControlEnvironmentOption", () => { + it("builds one option shared by the environment picker and federated Source Control", () => { + const projectId = ProjectId.make("project-remote"); + const project = { + environmentId: EnvironmentId.make("environment-remote"), + id: projectId, + workspaceRoot: "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/repos/project-remote", + scripts: [], + }; + + expect( + buildSourceControlEnvironmentOption({ + project, + label: "Build server", + isPrimary: false, + machine: "server", + connected: true, + preferredScriptId: "dev", + }), + ).toEqual({ + environmentId: project.environmentId, + projectId, + label: "Build server", + isPrimary: false, + machine: "server", + cwd: "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/repos/project-remote", + connected: true, + project: { + id: projectId, + workspaceRoot: "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/repos/project-remote", + scripts: [], + preferredScriptId: "dev", + }, + }); + }); +}); + +describe("source control right panel surface visibility", () => { + const sourceControlSurface = { id: "source-control", kind: "source-control" } as const; + const pullRequestSurface = { + id: "pull-request:project-a:pingdotgg%2Ft3code:6392", + kind: "pull-request", + projectId: "project-a", + repository: "pingdotgg/t3code", + number: 6392, + } as const; + const agentsSurface = { id: "agents", kind: "agents" } as const; + const assertActiveSourceControlSurface = ( + phase: string, + expectedSurfaces: readonly RightPanelSurface[], + ) => { + const byThreadKey = useRightPanelStore.getState().byThreadKey; + const panelState = selectThreadRightPanelState(byThreadKey, activeThreadRef); + const activeSurface = selectActiveRightPanelSurface(byThreadKey, activeThreadRef); + const visiblePresence = normalizeSourceControlRightPanelPresence({ + activeSurface, + sourceControlAvailable: true, + surfaces: panelState.surfaces, + }); + + expect(panelState.isOpen, `${phase}: panel is open`).toBe(true); + expect(panelState.activeSurfaceId, `${phase}: Source Control is active`).toBe("source-control"); + expect(panelState.surfaces, `${phase}: surface list`).toEqual(expectedSurfaces); + expect(visiblePresence.surfaces, `${phase}: Source Control is visible`).toContainEqual( + sourceControlSurface, + ); + expect(visiblePresence.activeSurface, `${phase}: visible active surface`).toEqual( + sourceControlSurface, + ); + }; + + it("requires both a thread ref and Git cwd before making Source Control available", () => { + expect(isSourceControlAvailable({ activeThreadRef, gitCwd: "/repo", isGitRepo: true })).toBe( + true, + ); + expect( + isSourceControlAvailable({ activeThreadRef: null, gitCwd: "/repo", isGitRepo: true }), + ).toBe(false); + expect(isSourceControlAvailable({ activeThreadRef, gitCwd: null, isGitRepo: true })).toBe( + false, + ); + expect(isSourceControlAvailable({ activeThreadRef, gitCwd: "/repo", isGitRepo: false })).toBe( + false, + ); + }); + + it("hides unavailable Source Control surfaces without affecting other surfaces", () => { + expect( + normalizeSourceControlRightPanelPresence({ + activeSurface: agentsSurface, + sourceControlAvailable: false, + surfaces: [sourceControlSurface, agentsSurface], + }), + ).toEqual({ activeSurface: agentsSurface, surfaces: [agentsSurface] }); + + const surfaces = [sourceControlSurface, agentsSurface]; + const normalized = normalizeSourceControlRightPanelPresence({ + activeSurface: sourceControlSurface, + sourceControlAvailable: true, + surfaces, + }); + expect(normalized.surfaces).toBe(surfaces); + expect(normalized.activeSurface).toBe(sourceControlSurface); + }); + + it("keeps pull-request tabs visible when Source Control becomes unavailable", () => { + const visiblePresence = normalizeSourceControlRightPanelPresence({ + activeSurface: sourceControlSurface, + sourceControlAvailable: false, + surfaces: [sourceControlSurface, pullRequestSurface, agentsSurface], + }); + + expect(visiblePresence).toEqual({ + activeSurface: pullRequestSurface, + surfaces: [pullRequestSurface, agentsSurface], + }); + }); + + it("falls back from an unavailable active Source Control surface to another visible surface", () => { + expect( + normalizeSourceControlRightPanelPresence({ + activeSurface: sourceControlSurface, + sourceControlAvailable: false, + surfaces: [sourceControlSurface, agentsSurface], + }), + ).toEqual({ activeSurface: agentsSurface, surfaces: [agentsSurface] }); + expect( + normalizeSourceControlRightPanelPresence({ + activeSurface: agentsSurface, + sourceControlAvailable: false, + surfaces: [sourceControlSurface, agentsSurface], + }), + ).toEqual({ activeSurface: agentsSurface, surfaces: [agentsSurface] }); + expect( + normalizeSourceControlRightPanelPresence({ + activeSurface: sourceControlSurface, + sourceControlAvailable: true, + surfaces: [sourceControlSurface, agentsSurface], + }), + ).toEqual({ + activeSurface: sourceControlSurface, + surfaces: [sourceControlSurface, agentsSurface], + }); + expect( + normalizeSourceControlRightPanelPresence({ + activeSurface: sourceControlSurface, + sourceControlAvailable: false, + surfaces: [sourceControlSurface], + }), + ).toEqual({ activeSurface: null, surfaces: [] }); + + const siblingFileSurface = { + id: fileSurfaceId("src/index.ts", "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/repo/sibling"), + kind: "file", + cwd: "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/repo/sibling", + relativePath: "src/index.ts", + revealLine: 12, + revealRequestId: 3, + } as const; + expect( + normalizeSourceControlRightPanelPresence({ + activeSurface: sourceControlSurface, + sourceControlAvailable: false, + surfaces: [sourceControlSurface, siblingFileSurface], + }), + ).toEqual({ activeSurface: siblingFileSurface, surfaces: [siblingFileSurface] }); + }); + + it("retargets an open singleton surface across grouped project drafts without leaking errors", () => { + const sharedDraftThreadId = ThreadId.make("grouped-draft"); + const groupedProjectARef = scopeThreadRef( + EnvironmentId.make("environment-project-a"), + sharedDraftThreadId, + ); + const groupedProjectBRef = scopeThreadRef( + EnvironmentId.make("environment-project-b"), + sharedDraftThreadId, + ); + const groupedProjectACwd = "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/repos/grouped-project-a"; + const groupedProjectBCwd = "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/repos/grouped-project-b"; + + useRightPanelStore.getState().open(groupedProjectARef, "source-control"); + + const initialByThreadKey = useRightPanelStore.getState().byThreadKey; + expect(selectActiveRightPanelSurface(initialByThreadKey, groupedProjectBRef)).toBeNull(); + + retargetOpenSourceControlSurface({ + currentThreadRef: groupedProjectARef, + nextThreadRef: groupedProjectBRef, + }); + + const byThreadKey = useRightPanelStore.getState().byThreadKey; + expect(selectThreadRightPanelState(byThreadKey, groupedProjectARef).surfaces).toEqual([ + sourceControlSurface, + ]); + expect(selectThreadRightPanelState(byThreadKey, groupedProjectBRef).surfaces).toEqual([ + sourceControlSurface, + ]); + const projectASurface = selectActiveRightPanelSurface(byThreadKey, groupedProjectARef); + const projectBSurface = selectActiveRightPanelSurface(byThreadKey, groupedProjectBRef); + expect( + resolveSourceControlPanelTarget({ + activeThreadRef: groupedProjectARef, + gitCwd: groupedProjectACwd, + surface: projectASurface, + }), + ).toEqual({ + environmentId: groupedProjectARef.environmentId, + threadId: groupedProjectARef.threadId, + cwd: groupedProjectACwd, + }); + expect( + resolveSourceControlPanelTarget({ + activeThreadRef: groupedProjectBRef, + gitCwd: groupedProjectBCwd, + surface: projectBSurface, + }), + ).toEqual({ + environmentId: groupedProjectBRef.environmentId, + threadId: groupedProjectBRef.threadId, + cwd: groupedProjectBCwd, + }); + + const projectAThreadKey = scopedThreadKey(groupedProjectARef); + const projectBThreadKey = scopedThreadKey(groupedProjectBRef); + const metadataErrors = { + [projectAThreadKey]: "stale project A metadata failure", + }; + + expect(selectSourceControlMetadataError(metadataErrors, projectAThreadKey)).toBe( + "stale project A metadata failure", + ); + expect(selectSourceControlMetadataError(metadataErrors, projectBThreadKey)).toBeNull(); + expect(retainThreadKeyRecord(metadataErrors, new Set([projectBThreadKey]))).toEqual({}); + }); + + it("does not update the store when retargeting to the same scoped thread", () => { + useRightPanelStore.getState().open(activeThreadRef, "source-control"); + const initialByThreadKey = useRightPanelStore.getState().byThreadKey; + + retargetOpenSourceControlSurface({ + currentThreadRef: activeThreadRef, + nextThreadRef: activeThreadRef, + }); + + expect(useRightPanelStore.getState().byThreadKey).toBe(initialByThreadKey); + }); + + it("keeps Source Control stable while preview stores change independently", () => { + const previewTabId = "background-preview"; + const previewSurface = { + id: `browser:${previewTabId}`, + kind: "preview", + resourceId: previewTabId, + } as const; + + useRightPanelStore.getState().open(activeThreadRef, "source-control"); + assertActiveSourceControlSurface("after opening Source Control", [sourceControlSurface]); + + usePreviewMiniPlayerStore + .getState() + .open(activeThreadRef, { kind: "browser", tabId: previewTabId }); + assertActiveSourceControlSurface("after opening the mini-player", [sourceControlSurface]); + expect( + selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + activeThreadRef, + ), + ).toMatchObject({ source: { kind: "browser", tabId: previewTabId } }); + + useRightPanelStore.getState().reconcileBrowserSurfaces(activeThreadRef, [previewTabId]); + assertActiveSourceControlSurface("after adding the browser surface", [ + sourceControlSurface, + previewSurface, + ]); + + useRightPanelStore.getState().reconcileBrowserSurfaces(activeThreadRef, []); + assertActiveSourceControlSurface("after removing the browser surface", [sourceControlSurface]); + expect( + selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + activeThreadRef, + ), + ).toMatchObject({ source: { kind: "browser", tabId: previewTabId } }); + + usePreviewMiniPlayerStore.getState().close(activeThreadRef); + assertActiveSourceControlSurface("after closing the mini-player", [sourceControlSurface]); + expect( + selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + activeThreadRef, + ), + ).toBeNull(); + }); + + it("keeps Source Control stable when the mini-player closes before browser reconciliation", () => { + const previewTabId = "background-preview"; + const previewSurface = { + id: `browser:${previewTabId}`, + kind: "preview", + resourceId: previewTabId, + } as const; + + useRightPanelStore.getState().open(activeThreadRef, "source-control"); + usePreviewMiniPlayerStore + .getState() + .open(activeThreadRef, { kind: "browser", tabId: previewTabId }); + useRightPanelStore.getState().reconcileBrowserSurfaces(activeThreadRef, [previewTabId]); + + usePreviewMiniPlayerStore.getState().close(activeThreadRef); + assertActiveSourceControlSurface("after closing the mini-player first", [ + sourceControlSurface, + previewSurface, + ]); + expect( + selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + activeThreadRef, + ), + ).toBeNull(); + + useRightPanelStore.getState().reconcileBrowserSurfaces(activeThreadRef, []); + assertActiveSourceControlSurface("after reconciling the browser surface", [ + sourceControlSurface, + ]); + }); +}); + +describe("runSourceControlServerMetadataUpdate", () => { + it("sends server-thread metadata and reports success", async () => { + const calls: unknown[] = []; + const result = await runSourceControlServerMetadataUpdate({ + activeThreadRef, + expectedBranch, + getCurrentSequence: () => 1, + metadata, + requestSequence: 1, + updateThreadMetadata: async (input) => { + calls.push(input); + return AsyncResult.success(undefined); + }, + }); + + expect(result).toEqual({ _tag: "Success" }); + expect(calls).toEqual([ + { + environmentId, + input: { + threadId, + branch: metadata.branch, + expectedBranch, + worktreePath: metadata.worktreePath, + }, + }, + ]); + }); + + it("drops stale results after a newer server-thread metadata request", async () => { + const result = await runSourceControlServerMetadataUpdate({ + activeThreadRef, + expectedBranch, + getCurrentSequence: () => 2, + metadata, + requestSequence: 1, + updateThreadMetadata: async () => AsyncResult.failure(Cause.fail("old failure")), + }); + + expect(result).toEqual({ _tag: "Stale" }); + }); + + it("serializes branch metadata updates and chains their expected branches", async () => { + const queue = createSourceControlServerMetadataUpdateQueue(); + const calls: Array<{ expectedBranch: string | null; branch: string | null }> = []; + const releases: Array<() => void> = []; + const updateThreadMetadata = async ( + input: Parameters[0]["updateThreadMetadata"]>[0], + ) => { + calls.push({ + expectedBranch: input.input.expectedBranch ?? null, + branch: input.input.branch ?? null, + }); + await new Promise((resolve) => releases.push(resolve)); + return AsyncResult.success(undefined); + }; + + const first = queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/one", worktreePath: null }, + updateThreadMetadata, + }); + const second = queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/two", worktreePath: null }, + updateThreadMetadata, + }); + + await Promise.resolve(); + expect(calls).toEqual([{ expectedBranch: "main", branch: "feature/one" }]); + releases.shift()?.(); + await first; + await Promise.resolve(); + expect(calls).toEqual([ + { expectedBranch: "main", branch: "feature/one" }, + { expectedBranch: "feature/one", branch: "feature/two" }, + ]); + releases.shift()?.(); + await expect(second).resolves.toEqual({ _tag: "Success" }); + }); + + it("keeps the last successful branch guard until the server state observes it", async () => { + const queue = createSourceControlServerMetadataUpdateQueue(); + const calls: Array<{ expectedBranch: string | null; branch: string | null }> = []; + const updateThreadMetadata = async ( + input: Parameters[0]["updateThreadMetadata"]>[0], + ) => { + calls.push({ + expectedBranch: input.input.expectedBranch ?? null, + branch: input.input.branch ?? null, + }); + return AsyncResult.success(undefined); + }; + + await queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/one", worktreePath: null }, + updateThreadMetadata, + }); + await Promise.resolve(); + queue.observe(activeThreadRef, "main"); + await queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/two", worktreePath: null }, + updateThreadMetadata, + }); + + expect(calls).toEqual([ + { expectedBranch: "main", branch: "feature/one" }, + { expectedBranch: "feature/one", branch: "feature/two" }, + ]); + }); + + it("accepts a later return to an old branch after the successful branch was observed", async () => { + const queue = createSourceControlServerMetadataUpdateQueue(); + const calls: Array<{ expectedBranch: string | null; branch: string | null }> = []; + const updateThreadMetadata = async ( + input: Parameters[0]["updateThreadMetadata"]>[0], + ) => { + calls.push({ + expectedBranch: input.input.expectedBranch ?? null, + branch: input.input.branch ?? null, + }); + return AsyncResult.success(undefined); + }; + + await queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/one", worktreePath: null }, + updateThreadMetadata, + }); + await Promise.resolve(); + queue.observe(activeThreadRef, "feature/one"); + queue.observe(activeThreadRef, "main"); + await queue.enqueue({ + activeThreadRef, + expectedBranch: "feature/one", + metadata: { branch: "feature/two", worktreePath: null }, + updateThreadMetadata, + }); + + expect(calls).toEqual([ + { expectedBranch: "main", branch: "feature/one" }, + { expectedBranch: "main", branch: "feature/two" }, + ]); + }); + + it("rejects stale observations from every unacknowledged chained write", async () => { + const queue = createSourceControlServerMetadataUpdateQueue(); + const calls: Array<{ expectedBranch: string | null; branch: string | null }> = []; + const updateThreadMetadata = async ( + input: Parameters[0]["updateThreadMetadata"]>[0], + ) => { + calls.push({ + expectedBranch: input.input.expectedBranch ?? null, + branch: input.input.branch ?? null, + }); + return AsyncResult.success(undefined); + }; + + await queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/one", worktreePath: null }, + updateThreadMetadata, + }); + await queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/two", worktreePath: null }, + updateThreadMetadata, + }); + await Promise.resolve(); + queue.observe(activeThreadRef, "main"); + await queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/three", worktreePath: null }, + updateThreadMetadata, + }); + + expect(calls).toEqual([ + { expectedBranch: "main", branch: "feature/one" }, + { expectedBranch: "feature/one", branch: "feature/two" }, + { expectedBranch: "feature/two", branch: "feature/three" }, + ]); + }); + + it("retains predecessor guards when an in-flight chained write observes its input", async () => { + const queue = createSourceControlServerMetadataUpdateQueue(); + const calls: Array<{ expectedBranch: string | null; branch: string | null }> = []; + let releaseSecond: (() => void) | undefined; + const updateThreadMetadata = async ( + input: Parameters[0]["updateThreadMetadata"]>[0], + ) => { + calls.push({ + expectedBranch: input.input.expectedBranch ?? null, + branch: input.input.branch ?? null, + }); + if (calls.length === 2) { + await new Promise((resolve) => { + releaseSecond = resolve; + }); + } + return AsyncResult.success(undefined); + }; + + await queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/one", worktreePath: null }, + updateThreadMetadata, + }); + const second = queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/two", worktreePath: null }, + updateThreadMetadata, + }); + await Promise.resolve(); + queue.observe(activeThreadRef, "feature/one"); + releaseSecond?.(); + await second; + await Promise.resolve(); + queue.observe(activeThreadRef, "main"); + await queue.enqueue({ + activeThreadRef, + expectedBranch: "feature/one", + metadata: { branch: "feature/three", worktreePath: null }, + updateThreadMetadata, + }); + + expect(calls).toEqual([ + { expectedBranch: "main", branch: "feature/one" }, + { expectedBranch: "feature/one", branch: "feature/two" }, + { expectedBranch: "feature/two", branch: "feature/three" }, + ]); + }); + + it("retains snapshotted predecessor guards after an in-flight acknowledgement", async () => { + const queue = createSourceControlServerMetadataUpdateQueue(); + const calls: Array<{ expectedBranch: string | null; branch: string | null }> = []; + let releaseSecond: (() => void) | undefined; + const updateThreadMetadata = async ( + input: Parameters[0]["updateThreadMetadata"]>[0], + ) => { + calls.push({ + expectedBranch: input.input.expectedBranch ?? null, + branch: input.input.branch ?? null, + }); + if (calls.length === 2) { + await new Promise((resolve) => { + releaseSecond = resolve; + }); + } + return AsyncResult.success(undefined); + }; + + await queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/one", worktreePath: null }, + updateThreadMetadata, + }); + const second = queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/two", worktreePath: null }, + updateThreadMetadata, + }); + await Promise.resolve(); + queue.observe(activeThreadRef, "feature/one"); + queue.observe(activeThreadRef, "main"); + releaseSecond?.(); + await second; + await queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/three", worktreePath: null }, + updateThreadMetadata, + }); + + expect(calls).toEqual([ + { expectedBranch: "main", branch: "feature/one" }, + { expectedBranch: "feature/one", branch: "feature/two" }, + { expectedBranch: "feature/two", branch: "feature/three" }, + ]); + }); + + it("accepts an observed request target that is also a snapshotted predecessor", async () => { + const queue = createSourceControlServerMetadataUpdateQueue(); + const calls: Array<{ expectedBranch: string | null; branch: string | null }> = []; + let releaseSecond: (() => void) | undefined; + const updateThreadMetadata = async ( + input: Parameters[0]["updateThreadMetadata"]>[0], + ) => { + calls.push({ + expectedBranch: input.input.expectedBranch ?? null, + branch: input.input.branch ?? null, + }); + if (calls.length === 2) { + await new Promise((resolve) => { + releaseSecond = resolve; + }); + } + return AsyncResult.success(undefined); + }; + + await queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/one", worktreePath: null }, + updateThreadMetadata, + }); + const second = queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "main", worktreePath: null }, + updateThreadMetadata, + }); + await Promise.resolve(); + queue.observe(activeThreadRef, "feature/one"); + queue.observe(activeThreadRef, "main"); + releaseSecond?.(); + await second; + queue.observe(activeThreadRef, "feature/one"); + await queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/two", worktreePath: null }, + updateThreadMetadata, + }); + + expect(calls).toEqual([ + { expectedBranch: "main", branch: "feature/one" }, + { expectedBranch: "feature/one", branch: "main" }, + { expectedBranch: "feature/one", branch: "feature/two" }, + ]); + }); + + it("rejects a request target observed as stale before its predecessor is acknowledged", async () => { + const queue = createSourceControlServerMetadataUpdateQueue(); + const calls: Array<{ expectedBranch: string | null; branch: string | null }> = []; + let releaseSecond: (() => void) | undefined; + const updateThreadMetadata = async ( + input: Parameters[0]["updateThreadMetadata"]>[0], + ) => { + calls.push({ + expectedBranch: input.input.expectedBranch ?? null, + branch: input.input.branch ?? null, + }); + if (calls.length === 2) { + await new Promise((resolve) => { + releaseSecond = resolve; + }); + } + return AsyncResult.success(undefined); + }; + + await queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/one", worktreePath: null }, + updateThreadMetadata, + }); + const second = queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "main", worktreePath: null }, + updateThreadMetadata, + }); + await Promise.resolve(); + queue.observe(activeThreadRef, "main"); + releaseSecond?.(); + await second; + queue.observe(activeThreadRef, "feature/one"); + await queue.enqueue({ + activeThreadRef, + expectedBranch: "feature/one", + metadata: { branch: "feature/two", worktreePath: null }, + updateThreadMetadata, + }); + + expect(calls).toEqual([ + { expectedBranch: "main", branch: "feature/one" }, + { expectedBranch: "feature/one", branch: "main" }, + { expectedBranch: "main", branch: "feature/two" }, + ]); + }); + + it("preserves the latest server observation received while a request is pending", async () => { + const queue = createSourceControlServerMetadataUpdateQueue(); + const calls: Array<{ expectedBranch: string | null; branch: string | null }> = []; + let releaseFirst: (() => void) | undefined; + const updateThreadMetadata = async ( + input: Parameters[0]["updateThreadMetadata"]>[0], + ) => { + calls.push({ + expectedBranch: input.input.expectedBranch ?? null, + branch: input.input.branch ?? null, + }); + if (calls.length === 1) { + await new Promise((resolve) => { + releaseFirst = resolve; + }); + } + return AsyncResult.success(undefined); + }; + + const first = queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/one", worktreePath: null }, + updateThreadMetadata, + }); + await Promise.resolve(); + queue.observe(activeThreadRef, "external/branch"); + releaseFirst?.(); + await first; + await Promise.resolve(); + await queue.enqueue({ + activeThreadRef, + expectedBranch: "external/branch", + metadata: { branch: "feature/two", worktreePath: null }, + updateThreadMetadata, + }); + + expect(calls).toEqual([ + { expectedBranch: "main", branch: "feature/one" }, + { expectedBranch: "external/branch", branch: "feature/two" }, + ]); + }); + + it("uses a server observation after a failed pending request for the next queued write", async () => { + const queue = createSourceControlServerMetadataUpdateQueue(); + const calls: Array<{ expectedBranch: string | null; branch: string | null }> = []; + let releaseFirst: (() => void) | undefined; + const updateThreadMetadata = async ( + input: Parameters[0]["updateThreadMetadata"]>[0], + ) => { + calls.push({ + expectedBranch: input.input.expectedBranch ?? null, + branch: input.input.branch ?? null, + }); + if (calls.length === 1) { + await new Promise((resolve) => { + releaseFirst = resolve; + }); + return AsyncResult.failure(Cause.fail("write failed")); + } + return AsyncResult.success(undefined); + }; + + const first = queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/one", worktreePath: null }, + updateThreadMetadata, + }); + const second = queue.enqueue({ + activeThreadRef, + expectedBranch: "main", + metadata: { branch: "feature/two", worktreePath: null }, + updateThreadMetadata, + }); + await Promise.resolve(); + queue.observe(activeThreadRef, "external/branch"); + releaseFirst?.(); + await first; + await second; + + expect(calls).toEqual([ + { expectedBranch: "main", branch: "feature/one" }, + { expectedBranch: "external/branch", branch: "feature/two" }, + ]); + }); + + it("drops stale thrown errors after a newer server-thread metadata request", async () => { + const result = await runSourceControlServerMetadataUpdate({ + activeThreadRef, + expectedBranch, + getCurrentSequence: () => 2, + metadata, + requestSequence: 1, + updateThreadMetadata: async () => { + throw { code: "NETWORK", message: "old network failure" }; + }, + }); + + expect(result).toEqual({ _tag: "Stale" }); + }); + + it("converts thrown update errors into controlled metadata failures", async () => { + const result = await runSourceControlServerMetadataUpdate({ + activeThreadRef, + expectedBranch, + getCurrentSequence: () => 1, + metadata, + requestSequence: 1, + updateThreadMetadata: async () => { + throw { code: "NETWORK", message: "network failed" }; + }, + }); + + expect(result).toEqual({ + _tag: "Failure", + message: "network failed (NETWORK)", + }); + }); + + it("keeps interrupted command results silent", async () => { + const result = await runSourceControlServerMetadataUpdate({ + activeThreadRef, + expectedBranch, + getCurrentSequence: () => 1, + metadata, + requestSequence: 1, + updateThreadMetadata: async () => AsyncResult.failure(Cause.interrupt(1)), + }); + + expect(result).toEqual({ _tag: "Interrupted" }); + }); + + it("uses the scoped thread key for server update sequencing callers", () => { + expect(scopedThreadKey(activeThreadRef)).toBe("environment-local:thread-1"); + }); +}); + +describe("federated source control project scripts", () => { + const local = EnvironmentId.make("local"); + const remote = EnvironmentId.make("remote"); + const projectId = ProjectId.make("shared-id"); + const script = { + id: "test", + name: "Test", + command: "vp test", + icon: "play" as const, + runOnWorktreeCreate: false, + }; + const scripts = [script]; + it.each([new Map(), new Map([[remote, { serverConfig: null }]])])( + "uses the legacy script patch before destination configuration is available", + (environments) => { + expect( + buildSourceControlProjectScriptPatch({ + environmentId: remote, + projectId, + scripts, + environments, + }), + ).toEqual({ projectScriptOverrides: { [projectId]: scripts } }); + }, + ); + const config = (modern: boolean, customInstructions: string) => ({ + serverConfig: { + environment: { capabilities: { projectSettingsOverrides: modern } }, + settings: { + projectSettingsOverrides: { + [projectId]: { + sourceControlWritingStyle: { + mode: "custom" as const, + customInstructions, + followChangeRequestTemplates: true, + }, + }, + }, + }, + }, + }); + + it("preserves destination overrides when both environments use the same project id", () => { + const target = config(true, "Remote instructions"); + const environments = new Map([ + [local, config(false, "Local instructions")], + [remote, target], + ]); + expect( + buildSourceControlProjectScriptPatch({ + environmentId: remote, + projectId, + scripts, + environments, + }), + ).toEqual({ + projectSettingsOverrides: { + [projectId]: { + ...target.serverConfig.settings.projectSettingsOverrides[projectId], + defaultProjectScripts: scripts, + }, + }, + }); + }); + + it("uses the destination legacy format even when the active environment is modern", () => { + const environments = new Map([ + [local, config(true, "Local instructions")], + [remote, config(false, "Remote instructions")], + ]); + expect( + buildSourceControlProjectScriptPatch({ + environmentId: remote, + projectId, + scripts: [], + environments, + }), + ).toEqual({ + projectScriptOverrides: { [projectId]: [] }, + }); + }); +}); diff --git a/apps/web/src/components/ChatView.sourceControl.ts b/apps/web/src/components/ChatView.sourceControl.ts new file mode 100644 index 000000000000..648e556bf3fa --- /dev/null +++ b/apps/web/src/components/ChatView.sourceControl.ts @@ -0,0 +1,629 @@ +import type { UpdateThreadMetadataInput } from "@t3tools/client-runtime/operations"; +import { + type EnvironmentId, + type EnvironmentMachineKind, + type ProjectId, + type ProjectScript, + type ServerSettings, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { ComposerThreadTarget, DraftId } from "../composerDraftStore"; +import { + selectActiveRightPanelSurface, + type RightPanelSurface, + useRightPanelStore, +} from "../rightPanelStore"; +import { + clearThreadErrorRecord, + retainThreadKeyRecord, + shouldApplySourceControlMetadataUpdateResult, +} from "./ChatView.logic"; +import type { EnvironmentOption } from "./BranchToolbar.logic"; +import type { SourceControlEnvironmentCandidate } from "./source-control/SourceControlPanel.logic"; + +type UpdateThreadMetadata = (input: { + readonly environmentId: EnvironmentId; + readonly input: UpdateThreadMetadataInput; +}) => Promise>; + +type SetDraftThreadContext = ( + target: ComposerThreadTarget, + context: { + readonly branch?: string | null; + readonly worktreePath?: string | null; + }, +) => void; + +interface SourceControlThreadRefChange { + readonly branch: string | null; + readonly worktreePath: string | null; +} + +interface UseSourceControlThreadMetadataRoutingInput { + readonly activeThreadRef: ScopedThreadRef | null; + readonly activeThreadKey: string | null; + readonly draftId: DraftId | null; + readonly expectedBranch: string | null; + readonly hasExpectedBranchObservation: boolean; + readonly existingThreadKeys: ReadonlySet; + readonly isServerThread: boolean; + readonly setDraftThreadContext: SetDraftThreadContext; + readonly updateThreadMetadata: UpdateThreadMetadata; +} + +interface SourceControlThreadMetadataRouting { + readonly sourceControlMetadataError: string | null; + readonly clearActiveSourceControlMetadataError: () => void; + readonly handleSourceControlThreadRefChange: ( + input: SourceControlThreadRefChange, + ) => Promise; +} + +interface UseSourceControlRightPanelSurfaceInput { + readonly activeRightPanelSurface: RightPanelSurface | null; + readonly activeThreadRef: ScopedThreadRef | null; + readonly gitCwd: string | null; + readonly isGitRepo: boolean; + readonly rightPanelSurfaces: readonly RightPanelSurface[]; +} + +interface SourceControlRightPanelSurfaceState { + readonly addSourceControlSurface: () => void; + readonly sourceControlAvailable: boolean; + readonly visibleActiveRightPanelSurface: RightPanelSurface | null; + readonly visibleRightPanelSurfaces: readonly RightPanelSurface[]; +} + +interface SourceControlPanelTarget { + readonly environmentId: EnvironmentId; + readonly threadId: ScopedThreadRef["threadId"]; + readonly cwd: string; +} + +export type SourceControlEnvironmentOption = EnvironmentOption & + Omit & { + readonly project: NonNullable; + }; + +/** Build the patch from the destination server, including when its project id is also used locally. */ +export function buildSourceControlProjectScriptPatch(input: { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly scripts: readonly ProjectScript[]; + readonly environments: ReadonlyMap< + string, + { + readonly serverConfig: { + readonly environment: { + readonly capabilities: { readonly projectSettingsOverrides?: boolean }; + }; + readonly settings: Pick; + } | null; + } + >; +}) { + const config = input.environments.get(input.environmentId)?.serverConfig; + return config?.environment.capabilities.projectSettingsOverrides === true + ? { + projectSettingsOverrides: { + [input.projectId]: { + ...config.settings.projectSettingsOverrides[input.projectId], + defaultProjectScripts: input.scripts, + }, + }, + } + : { projectScriptOverrides: { [input.projectId]: input.scripts } }; +} + +export function buildSourceControlEnvironmentOption(input: { + readonly project: { + readonly environmentId: EnvironmentId; + readonly id: ProjectId; + readonly workspaceRoot: string; + readonly scripts: readonly ProjectScript[]; + }; + readonly label: string; + readonly isPrimary: boolean; + readonly machine: EnvironmentMachineKind; + readonly connected: boolean; + readonly preferredScriptId: string | null; +}): SourceControlEnvironmentOption { + return { + environmentId: input.project.environmentId, + projectId: input.project.id, + label: input.label, + isPrimary: input.isPrimary, + machine: input.machine, + cwd: input.project.workspaceRoot, + connected: input.connected, + project: { + id: input.project.id, + workspaceRoot: input.project.workspaceRoot, + scripts: input.project.scripts, + preferredScriptId: input.preferredScriptId, + }, + }; +} + +interface SourceControlServerMetadataUpdateInput { + readonly activeThreadRef: ScopedThreadRef; + readonly expectedBranch: string | null; + readonly metadata: SourceControlThreadRefChange; + readonly requestSequence: number; + readonly getCurrentSequence: () => number | undefined; + readonly updateThreadMetadata: UpdateThreadMetadata; +} + +interface QueuedSourceControlServerMetadataUpdateInput { + readonly activeThreadRef: ScopedThreadRef; + readonly expectedBranch: string | null; + readonly metadata: SourceControlThreadRefChange; + readonly updateThreadMetadata: UpdateThreadMetadata; +} + +type SourceControlServerMetadataUpdateResult = + | { + readonly _tag: "Success"; + } + | { + readonly _tag: "Stale"; + } + | { + readonly _tag: "Interrupted"; + } + | { + readonly _tag: "Failure"; + readonly message: string; + }; + +export type ThreadErrorSource = "draft" | "local-server" | "source-control" | "session"; + +export function resolveThreadErrorDismissAction( + source: ThreadErrorSource | null, +): "clear-thread" | "clear-source-control" | "mask-only" { + if (source === "draft" || source === "local-server") return "clear-thread"; + if (source === "source-control") return "clear-source-control"; + return "mask-only"; +} + +export function resolveThreadErrorPresentation(input: { + readonly isServerThread: boolean; + readonly localDraftError: string | null; + readonly localServerError: string | null; + readonly sessionError: string | null; + readonly sourceControlMetadataError: string | null; +}): { readonly error: string | null; readonly source: ThreadErrorSource | null } { + if (!input.isServerThread) { + return input.localDraftError === null + ? { error: null, source: null } + : { error: input.localDraftError, source: "draft" }; + } + if (input.localServerError !== null) { + return { error: input.localServerError, source: "local-server" }; + } + if (input.sourceControlMetadataError !== null) { + return { error: input.sourceControlMetadataError, source: "source-control" }; + } + return input.sessionError === null + ? { error: null, source: null } + : { error: input.sessionError, source: "session" }; +} + +export function sourceControlMetadataErrorFromFailure(error: unknown): string { + if (typeof error === "string") { + return error; + } + if (error instanceof Error) { + return error.message; + } + if (error && typeof error === "object") { + const message = "message" in error ? error.message : null; + const code = "code" in error ? error.code : null; + if (typeof message === "string" && message.length > 0) { + return typeof code === "string" && code.length > 0 ? `${message} (${code})` : message; + } + try { + const serialized = JSON.stringify(error); + if (serialized && serialized !== "{}") { + return serialized; + } + } catch { + return "Failed to update thread source control."; + } + } + return "Failed to update thread source control."; +} + +export function resolveSourceControlDraftMetadataTarget(input: { + readonly draftId: DraftId | null; + readonly activeThreadRef: ScopedThreadRef | null; +}): ComposerThreadTarget | null { + return input.draftId ?? input.activeThreadRef; +} + +export function isSourceControlAvailable(input: { + readonly activeThreadRef: ScopedThreadRef | null; + readonly gitCwd: string | null; + readonly isGitRepo: boolean; +}): boolean { + return input.activeThreadRef !== null && input.gitCwd !== null && input.isGitRepo; +} + +export function normalizeSourceControlRightPanelPresence(input: { + readonly surfaces: readonly RightPanelSurface[]; + readonly activeSurface: RightPanelSurface | null; + readonly sourceControlAvailable: boolean; +}): { + readonly surfaces: readonly RightPanelSurface[]; + readonly activeSurface: RightPanelSurface | null; +} { + const surfaces = input.sourceControlAvailable + ? input.surfaces + : input.surfaces.filter((surface) => surface.kind !== "source-control"); + const activeSurface = + input.activeSurface?.kind === "source-control" && !input.sourceControlAvailable + ? (surfaces[0] ?? null) + : input.activeSurface; + return { activeSurface, surfaces }; +} + +export function resolveSourceControlPanelTarget(input: { + readonly activeThreadRef: ScopedThreadRef | null; + readonly gitCwd: string | null; + readonly surface: RightPanelSurface | null; +}): SourceControlPanelTarget | null { + if (input.surface?.kind !== "source-control" || !input.activeThreadRef || !input.gitCwd) { + return null; + } + return { + environmentId: input.activeThreadRef.environmentId, + threadId: input.activeThreadRef.threadId, + cwd: input.gitCwd, + }; +} + +export function selectSourceControlMetadataError( + metadataErrorsByThreadKey: Readonly>, + activeThreadKey: string | null, +): string | null { + return activeThreadKey === null ? null : (metadataErrorsByThreadKey[activeThreadKey] ?? null); +} + +export function retargetOpenSourceControlSurface(input: { + readonly currentThreadRef: ScopedThreadRef; + readonly nextThreadRef: ScopedThreadRef; +}): void { + if (scopedThreadKey(input.currentThreadRef) === scopedThreadKey(input.nextThreadRef)) return; + const store = useRightPanelStore.getState(); + const activeSurface = selectActiveRightPanelSurface(store.byThreadKey, input.currentThreadRef); + if (activeSurface?.kind !== "source-control") return; + store.open(input.nextThreadRef, "source-control"); +} + +export async function runSourceControlServerMetadataUpdate( + input: SourceControlServerMetadataUpdateInput, +): Promise { + const { + activeThreadRef, + expectedBranch, + getCurrentSequence, + metadata, + requestSequence, + updateThreadMetadata, + } = input; + let result: AtomCommandResult; + try { + result = await updateThreadMetadata({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadRef.threadId, + branch: metadata.branch, + expectedBranch, + worktreePath: metadata.worktreePath, + }, + }); + } catch (error) { + if ( + !shouldApplySourceControlMetadataUpdateResult({ + currentSequence: getCurrentSequence(), + requestSequence, + }) + ) { + return { _tag: "Stale" }; + } + return { + _tag: "Failure", + message: sourceControlMetadataErrorFromFailure(error), + }; + } + + if ( + !shouldApplySourceControlMetadataUpdateResult({ + currentSequence: getCurrentSequence(), + requestSequence, + }) + ) { + return { _tag: "Stale" }; + } + if (result._tag === "Success") { + return { _tag: "Success" }; + } + if (isAtomCommandInterrupted(result)) { + return { _tag: "Interrupted" }; + } + return { + _tag: "Failure", + message: sourceControlMetadataErrorFromFailure(squashAtomCommandFailure(result)), + }; +} + +export function createSourceControlServerMetadataUpdateQueue() { + const expectedBranchByThreadKey = new Map< + string, + { + branch: string | null; + observationSequence: number; + latestObservedBranch: string | null; + latestObservationIsStale: boolean; + pendingTransition: { + staleBranches: ReadonlySet; + to: string | null; + } | null; + } + >(); + const pendingByThreadKey = new Map>(); + const sequenceByThreadKey = new Map(); + + const observe = (activeThreadRef: ScopedThreadRef, expectedBranch: string | null) => { + const targetThreadKey = scopedThreadKey(activeThreadRef); + const expected = expectedBranchByThreadKey.get(targetThreadKey); + if (!expected) { + expectedBranchByThreadKey.set(targetThreadKey, { + branch: expectedBranch, + latestObservedBranch: expectedBranch, + latestObservationIsStale: false, + observationSequence: 1, + pendingTransition: null, + }); + return; + } + expected.latestObservedBranch = expectedBranch; + expected.latestObservationIsStale = false; + expected.observationSequence += 1; + const transition = expected.pendingTransition; + if (transition && expectedBranch === transition.to) { + expected.branch = expectedBranch; + expected.pendingTransition = null; + return; + } + if (transition?.staleBranches.has(expectedBranch)) { + expected.latestObservationIsStale = true; + return; + } + expected.branch = expectedBranch; + expected.pendingTransition = null; + }; + + return { + observe, + enqueue(input: QueuedSourceControlServerMetadataUpdateInput) { + const targetThreadKey = scopedThreadKey(input.activeThreadRef); + const previous = pendingByThreadKey.get(targetThreadKey); + if (!expectedBranchByThreadKey.has(targetThreadKey)) { + expectedBranchByThreadKey.set(targetThreadKey, { + branch: input.expectedBranch, + latestObservedBranch: input.expectedBranch, + latestObservationIsStale: false, + observationSequence: 0, + pendingTransition: null, + }); + } + + const result = (previous ?? Promise.resolve()).then(async () => { + const expectedBranchState = expectedBranchByThreadKey.get(targetThreadKey); + const requestExpectedBranch = expectedBranchState?.branch ?? null; + const requestStaleBranches = new Set( + expectedBranchState?.pendingTransition?.staleBranches ?? [], + ); + const observationSequence = expectedBranchState?.observationSequence ?? 0; + const requestSequence = (sequenceByThreadKey.get(targetThreadKey) ?? 0) + 1; + sequenceByThreadKey.set(targetThreadKey, requestSequence); + const updateResult = await runSourceControlServerMetadataUpdate({ + activeThreadRef: input.activeThreadRef, + expectedBranch: requestExpectedBranch, + getCurrentSequence: () => sequenceByThreadKey.get(targetThreadKey), + metadata: input.metadata, + requestSequence, + updateThreadMetadata: input.updateThreadMetadata, + }); + if (updateResult._tag === "Success") { + const latestExpectedBranchState = expectedBranchByThreadKey.get(targetThreadKey); + if (!latestExpectedBranchState) return updateResult; + const observedDuringRequest = + latestExpectedBranchState.observationSequence !== observationSequence; + if ( + observedDuringRequest && + !latestExpectedBranchState.latestObservationIsStale && + latestExpectedBranchState.latestObservedBranch === input.metadata.branch + ) { + latestExpectedBranchState.branch = input.metadata.branch; + latestExpectedBranchState.pendingTransition = null; + } else if ( + !observedDuringRequest || + latestExpectedBranchState.latestObservationIsStale || + requestStaleBranches.has(latestExpectedBranchState.latestObservedBranch) || + latestExpectedBranchState.latestObservedBranch === requestExpectedBranch + ) { + const staleBranches = new Set(requestStaleBranches); + for (const staleBranch of latestExpectedBranchState.pendingTransition?.staleBranches ?? + []) { + staleBranches.add(staleBranch); + } + staleBranches.add(requestExpectedBranch); + latestExpectedBranchState.branch = input.metadata.branch; + latestExpectedBranchState.pendingTransition = { + staleBranches, + to: input.metadata.branch, + }; + } + } + return updateResult; + }); + const pending = result.then( + () => undefined, + () => undefined, + ); + pendingByThreadKey.set(targetThreadKey, pending); + void pending.finally(() => { + if (pendingByThreadKey.get(targetThreadKey) === pending) { + pendingByThreadKey.delete(targetThreadKey); + } + }); + return result; + }, + }; +} + +export function useSourceControlRightPanelSurfaceState( + input: UseSourceControlRightPanelSurfaceInput, +): SourceControlRightPanelSurfaceState { + const { activeRightPanelSurface, activeThreadRef, gitCwd, isGitRepo, rightPanelSurfaces } = input; + const sourceControlAvailable = isSourceControlAvailable({ + activeThreadRef, + gitCwd, + isGitRepo, + }); + const visiblePresence = useMemo( + () => + normalizeSourceControlRightPanelPresence({ + activeSurface: activeRightPanelSurface, + sourceControlAvailable, + surfaces: rightPanelSurfaces, + }), + [activeRightPanelSurface, rightPanelSurfaces, sourceControlAvailable], + ); + const addSourceControlSurface = useCallback(() => { + if (!activeThreadRef || !sourceControlAvailable) return; + useRightPanelStore.getState().open(activeThreadRef, "source-control"); + }, [activeThreadRef, sourceControlAvailable]); + + return { + addSourceControlSurface, + sourceControlAvailable, + visibleActiveRightPanelSurface: visiblePresence.activeSurface, + visibleRightPanelSurfaces: visiblePresence.surfaces, + }; +} + +export function useSourceControlThreadMetadataRouting( + input: UseSourceControlThreadMetadataRoutingInput, +): SourceControlThreadMetadataRouting { + const { + activeThreadKey, + activeThreadRef, + draftId, + expectedBranch, + hasExpectedBranchObservation, + existingThreadKeys, + isServerThread, + setDraftThreadContext, + updateThreadMetadata, + } = input; + const metadataUpdateQueueRef = useRef | null>(null); + const metadataUpdateQueue = + metadataUpdateQueueRef.current ?? createSourceControlServerMetadataUpdateQueue(); + metadataUpdateQueueRef.current = metadataUpdateQueue; + const [metadataErrorsByThreadKey, setMetadataErrorsByThreadKey] = useState< + Record + >({}); + const sourceControlMetadataError = selectSourceControlMetadataError( + metadataErrorsByThreadKey, + activeThreadKey, + ); + const activeThreadEnvironmentId = activeThreadRef?.environmentId ?? null; + const activeThreadId = activeThreadRef?.threadId ?? null; + const activeThreadMetadataRef = useMemo(() => { + if (activeThreadEnvironmentId === null || activeThreadId === null) return null; + return { + environmentId: activeThreadEnvironmentId, + threadId: activeThreadId, + }; + }, [activeThreadEnvironmentId, activeThreadId]); + + useEffect(() => { + setMetadataErrorsByThreadKey((existing) => retainThreadKeyRecord(existing, existingThreadKeys)); + }, [existingThreadKeys]); + + useEffect(() => { + if (!activeThreadMetadataRef || !hasExpectedBranchObservation) return; + metadataUpdateQueue.observe(activeThreadMetadataRef, expectedBranch); + }, [activeThreadMetadataRef, expectedBranch, hasExpectedBranchObservation, metadataUpdateQueue]); + + const clearActiveSourceControlMetadataError = useCallback(() => { + // Draft metadata changes are local store updates and do not create dismissible metadata errors. + if (!isServerThread || activeThreadKey === null) return; + setMetadataErrorsByThreadKey((existing) => clearThreadErrorRecord(existing, activeThreadKey)); + }, [activeThreadKey, isServerThread]); + + const handleSourceControlThreadRefChange = useCallback( + async (metadata: SourceControlThreadRefChange) => { + if (!isServerThread) { + const target = resolveSourceControlDraftMetadataTarget({ + activeThreadRef: activeThreadMetadataRef, + draftId, + }); + if (!target) return; + setDraftThreadContext(target, { + branch: metadata.branch, + worktreePath: metadata.worktreePath, + }); + return; + } + + if (!activeThreadMetadataRef) return; + const targetThreadKey = scopedThreadKey(activeThreadMetadataRef); + const result = await metadataUpdateQueue.enqueue({ + activeThreadRef: activeThreadMetadataRef, + expectedBranch, + metadata, + updateThreadMetadata, + }); + if (result._tag === "Success") { + setMetadataErrorsByThreadKey((existing) => + clearThreadErrorRecord(existing, targetThreadKey), + ); + return; + } + if (result._tag === "Stale" || result._tag === "Interrupted") return; + setMetadataErrorsByThreadKey((existing) => ({ + ...existing, + [targetThreadKey]: result.message, + })); + }, + [ + activeThreadMetadataRef, + draftId, + expectedBranch, + isServerThread, + metadataUpdateQueue, + setDraftThreadContext, + updateThreadMetadata, + ], + ); + + return { + clearActiveSourceControlMetadataError, + handleSourceControlThreadRefChange, + sourceControlMetadataError, + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a32c1473cda7..609f1af83e0a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -183,9 +183,11 @@ import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { pullRequestSurface, + fileSurfaceId, selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, + terminalSurfaceId, type RightPanelSurface, useRightPanelStore, } from "../rightPanelStore"; @@ -246,6 +248,7 @@ import { cn, randomHex, randomUUID } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; +import type { SourceControlProjectActionTarget } from "./source-control/SourceControlPanel"; import { buildProjectScript, commandForProjectScript, @@ -381,7 +384,6 @@ import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImag import { NoActiveThreadState } from "./NoActiveThreadState"; import { WorkspacePageHeader } from "./WorkspacePageHeader"; import { - type EnvironmentOption, resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, shouldShowComposerContextStrip, @@ -475,11 +477,23 @@ import { revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, + isLatestRequestSequence, startNewThreadForProject, codexArtifactTemplatePromptToAppend, waitForStartedServerThread, shouldRefocusComposerOnWindowFocus, } from "./ChatView.logic"; +import { + buildSourceControlEnvironmentOption, + buildSourceControlProjectScriptPatch, + normalizeSourceControlRightPanelPresence, + resolveThreadErrorDismissAction, + resolveThreadErrorPresentation, + resolveSourceControlPanelTarget, + retargetOpenSourceControlSurface, + useSourceControlRightPanelSurfaceState, + useSourceControlThreadMetadataRouting, +} from "./ChatView.sourceControl"; import type { ThreadSyncPhase } from "../threadSync"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; @@ -623,6 +637,11 @@ const DevicePanel = lazy(() => import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), ); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); +const SourceControlPanel = lazy(() => + import("./source-control/SourceControlPanel").then((module) => ({ + default: module.SourceControlPanel, + })), +); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", @@ -1307,26 +1326,44 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane newShortcutLabel, closeShortcutLabel, }: PersistentThreadTerminalPanelProps) { - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; + const terminalThreadRef = useMemo( + () => + surface.target + ? scopeThreadRef(surface.target.environmentId as EnvironmentId, threadRef.threadId) + : threadRef, + [surface.target, threadRef], + ); + const draftThread = useComposerDraftStore((store) => + surface.target ? null : store.getDraftThreadByRef(threadRef), + ); + const serverThread = useThread(surface.target ? null : threadRef, { + waitForShell: draftThread !== null, + }); + const projectRef = surface.target + ? scopeProjectRef( + surface.target.environmentId as EnvironmentId, + surface.target.projectId as ProjectId, + ) + : serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; const project = useProject(projectRef); const knownTerminalSessions = useKnownTerminalSessions({ - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, + environmentId: terminalThreadRef.environmentId, + threadId: terminalThreadRef.threadId, }); const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const activeSummary = knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) ?.state.summary ?? null; - const worktreePath = - launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; + const worktreePath = surface.target + ? surface.target.worktreePath + : (launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath); const cwd = useMemo( () => + surface.target?.cwd ?? launchContext?.cwd ?? activeSummary?.cwd ?? (project @@ -1335,7 +1372,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane worktreePath, }) : null), - [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], + [activeSummary?.cwd, launchContext?.cwd, project, surface.target?.cwd, worktreePath], ); const runtimeEnv = useMemo( () => @@ -1370,9 +1407,11 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane const summary = knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state .summary ?? null; - const terminalWorktreePath = - launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; + const terminalWorktreePath = surface.target + ? surface.target.worktreePath + : (launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath); const terminalCwd = + surface.target?.cwd ?? launchContext?.cwd ?? summary?.cwd ?? (project @@ -1397,6 +1436,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane launchContext?.cwd, launchContext?.worktreePath, project, + surface.target, surface.terminalIds, threadWorktreePath, ]); @@ -1407,8 +1447,8 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); + const activePanelTerminalThreadRef = useMemo( + () => + activeRightPanelSurface?.kind === "terminal" && + activeRightPanelSurface.target && + activeThreadId + ? scopeThreadRef( + activeRightPanelSurface.target.environmentId as EnvironmentId, + activeThreadId, + ) + : activeThreadRef, + [activeRightPanelSurface, activeThreadId, activeThreadRef], + ); + const activePanelTerminalSessions = useKnownTerminalSessions({ + environmentId: activePanelTerminalThreadRef?.environmentId ?? null, + threadId: activePanelTerminalThreadRef?.threadId ?? null, + }); + const allocatablePanelTerminalIds = useMemo( + () => [ + ...new Set([ + ...activePanelTerminalSessions.map((session) => session.target.terminalId), + ...(activeRightPanelSurface?.kind === "terminal" + ? activeRightPanelSurface.terminalIds + : []), + ]), + ], + [activePanelTerminalSessions, activeRightPanelSurface], + ); const activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewServerEpoch = activePreviewState.serverEpoch; const resolvePreviewRuntimeTabId = useMemo( @@ -2064,34 +2114,6 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey, panelAnimationDurationMs, ); - const rightPanelPresenceValue = useMemo( - () => ({ - activeSurface: activeRightPanelSurface, - surfaces: rightPanelState.surfaces, - }), - [activeRightPanelSurface, rightPanelState.surfaces], - ); - const rightPanelPresence = usePanelPresence( - rightPanelOpen && activeThreadRef !== null, - rightPanelPresenceValue, - panelAnimationsActive, - activeThreadKey, - panelAnimationDurationMs, - ); - const rightPanelPresent = rightPanelPresence.present; - const rightPanelControlsInPanel = shouldUseRightPanelSheet && rightPanelPresent && rightPanelOpen; - const rightPanelControlsAtRoot = rightPanelPresent && !shouldUseRightPanelSheet; - const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; - const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; - const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( - activePreviewMiniPlayer?.source ?? null, - renderedRightPanelSurface, - ); - const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; - const rightPanelMaximized = - canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; - const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; - useEffect(() => { if (!activeThreadRef) return; useRightPanelStore @@ -2109,10 +2131,47 @@ export default function ChatView(props: ChatViewProps) { } }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]); - const existingOpenTerminalThreadKeys = useMemo(() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); + const existingThreadKeys = useMemo(() => { + const threadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); + if (activeThreadKey) { + threadKeys.add(activeThreadKey); + } + return threadKeys; + }, [activeThreadKey, draftThreadKeys, serverThreadKeys]); + const existingOpenTerminalThreadKeys = useMemo( + () => openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)), + [existingThreadKeys, openTerminalThreadKeys], + ); + const { + clearActiveSourceControlMetadataError, + handleSourceControlThreadRefChange, + sourceControlMetadataError, + } = useSourceControlThreadMetadataRouting({ + activeThreadKey, + activeThreadRef, + draftId, + expectedBranch: activeServerThread?.branch ?? null, + hasExpectedBranchObservation: activeServerThread !== null, + existingThreadKeys, + isServerThread, + setDraftThreadContext, + updateThreadMetadata, + }); + const { error: threadError, source: threadErrorSource } = resolveThreadErrorPresentation({ + isServerThread, + localDraftError, + localServerError, + sessionError: activeServerThread?.session?.lastError ?? null, + sourceControlMetadataError, + }); + const threadErrorBannerKey = getThreadErrorBannerKey(routeThreadKey, threadError); + const visibleThreadError = shouldShowThreadErrorBanner( + routeThreadKey, + threadError, + isThreadErrorBannerDismissedForSession(threadErrorBannerKey), + ) + ? threadError + : null; const activeLatestTurn = activeThread?.latestTurn ?? null; const activeRunningTurnId = (activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ?? @@ -2276,6 +2335,78 @@ export default function ChatView(props: ChatViewProps) { runProjectCloneAction, ]); const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; + const gitCwd = activeProject + ? projectScriptCwd({ + project: { cwd: activeProject.workspaceRoot }, + worktreePath: activeThread?.worktreePath ?? null, + }) + : null; + const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; + const gitStatusQuery = useEnvironmentQuery( + gitStatusCwd === null + ? null + : vcsEnvironment.status({ + environmentId, + input: { cwd: gitStatusCwd }, + }), + ); + // Git status arrives after the composer paints. A checkout seen earlier in + // this session answers from memory, so a non-Git project does not mount the + // branch strip and then drop it. A never-seen checkout assumes Git, which + // is what nearly every project is. + const liveIsGitRepo = gitStatusQuery.data?.isRepo; + useEffect(() => { + if (gitStatusCwd !== null && liveIsGitRepo !== undefined) { + rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo); + } + }, [environmentId, gitStatusCwd, liveIsGitRepo]); + const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; + const { + addSourceControlSurface, + sourceControlAvailable, + visibleActiveRightPanelSurface, + visibleRightPanelSurfaces, + } = useSourceControlRightPanelSurfaceState({ + activeRightPanelSurface, + activeThreadRef, + gitCwd, + isGitRepo, + rightPanelSurfaces: rightPanelState.surfaces, + }); + const rightPanelPresenceValue = useMemo( + () => ({ + activeSurface: visibleActiveRightPanelSurface, + surfaces: visibleRightPanelSurfaces, + }), + [visibleActiveRightPanelSurface, visibleRightPanelSurfaces], + ); + const rightPanelPresence = usePanelPresence( + rightPanelOpen && activeThreadRef !== null, + rightPanelPresenceValue, + panelAnimationsActive, + activeThreadKey, + panelAnimationDurationMs, + ); + const rightPanelPresent = rightPanelPresence.present; + const rightPanelControlsInPanel = shouldUseRightPanelSheet && rightPanelPresent && rightPanelOpen; + const rightPanelControlsAtRoot = rightPanelPresent && !shouldUseRightPanelSheet; + const renderedRightPanelPresence = normalizeSourceControlRightPanelPresence({ + activeSurface: rightPanelPresence.value?.activeSurface ?? null, + surfaces: rightPanelPresence.value?.surfaces ?? [], + sourceControlAvailable, + }); + const renderedRightPanelSurfaces = renderedRightPanelPresence.surfaces; + const renderedRightPanelSurface = renderedRightPanelPresence.activeSurface; + const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( + activePreviewMiniPlayer?.source ?? null, + renderedRightPanelSurface, + ); + const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; + const rightPanelMaximized = + canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; + const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; + const activeFileSurface = + renderedRightPanelSurface?.kind === "file" ? renderedRightPanelSurface : null; const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); @@ -2308,9 +2439,10 @@ export default function ChatView(props: ChatViewProps) { const handleFilePendingChange = useCallback( (relativePath: string, pending: boolean) => { if (!activeProjectKey) return; + const cwd = activeFileSurface?.cwd; setPendingFileSurfaceIdsByProject((currentByProject) => { const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; - const surfaceId = `file:${relativePath}`; + const surfaceId = fileSurfaceId(relativePath, cwd); if (current.has(surfaceId) === pending) return currentByProject; const next = new Set(current); if (pending) next.add(surfaceId); @@ -2321,7 +2453,7 @@ export default function ChatView(props: ChatViewProps) { return nextByProject; }); }, - [activeProjectKey], + [activeFileSurface?.cwd, activeProjectKey], ); const configuredPreviewUrls = useMemo( () => getConfiguredPreviewUrls(activeProjectScripts), @@ -2455,19 +2587,27 @@ export default function ChatView(props: ChatViewProps) { (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, ); const seen = new Set(); - const envs: EnvironmentOption[] = []; + const envs = []; for (const p of memberProjects) { if (seen.has(p.environmentId)) continue; seen.add(p.environmentId); const isPrimary = p.environmentId === primaryEnvironmentId; const environment = environmentById.get(p.environmentId) ?? null; - envs.push({ - environmentId: p.environmentId, - projectId: p.id, - label: environment?.label ?? p.environmentId, - isPrimary, - machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null), - }); + envs.push( + buildSourceControlEnvironmentOption({ + project: { + ...p, + scripts: environment?.serverConfig + ? resolveProjectScripts(environment.serverConfig.settings, p) + : p.scripts, + }, + label: environment?.label ?? p.environmentId, + isPrimary, + machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null), + connected: environment?.connection.phase === "connected", + preferredScriptId: lastInvokedScriptByProjectId[p.id] ?? null, + }), + ); } // Sort: primary first, then alphabetical envs.sort((a, b) => { @@ -2475,7 +2615,14 @@ export default function ChatView(props: ChatViewProps) { return a.label.localeCompare(b.label); }); return envs; - }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); + }, [ + activeProject, + allProjects, + projectGroupingSettings, + primaryEnvironmentId, + environmentById, + lastInvokedScriptByProjectId, + ]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; const activeEnvironmentOption = logicalProjectEnvironments.find( @@ -3623,21 +3770,11 @@ export default function ChatView(props: ChatViewProps) { panelAnimationDurationMs, ); - const gitCwd = activeProject - ? projectScriptCwd({ - project: { cwd: activeProject.workspaceRoot }, - worktreePath: activeThread?.worktreePath ?? null, - }) - : null; - const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; - const gitStatusQuery = useEnvironmentQuery( - gitStatusCwd === null - ? null - : vcsEnvironment.status({ - environmentId, - input: { cwd: gitStatusCwd }, - }), - ); + const sourceControlPanelTarget = resolveSourceControlPanelTarget({ + activeThreadRef, + gitCwd, + surface: renderedRightPanelSurface, + }); useWorkspaceMutationRefresh({ enabled: gitStatusCwd !== null, mutationId: workspaceMutationId, @@ -3725,17 +3862,6 @@ export default function ChatView(props: ChatViewProps) { : null; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; - // Git status arrives after the composer paints. A checkout seen earlier in - // this session answers from memory, so a non-Git project does not mount the - // branch strip and then drop it. A never-seen checkout assumes Git, which - // is what nearly every project is. - const liveIsGitRepo = gitStatusQuery.data?.isRepo; - useEffect(() => { - if (gitStatusCwd !== null && liveIsGitRepo !== undefined) { - rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo); - } - }, [environmentId, gitStatusCwd, liveIsGitRepo]); - const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; // Keep a hidden, off-flow strip mounted for existing threads so the composer // can measure whether its relocated controls fit. The visible chrome remains // content-driven: Git/environment context or controls that actually fit. @@ -3895,13 +4021,19 @@ export default function ChatView(props: ChatViewProps) { (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; + if (activeThreadRef) { + retargetOpenSourceControlSurface({ + currentThreadRef: activeThreadRef, + nextThreadRef: scopeThreadRef(target.environmentId, activeThreadRef.threadId), + }); + } setDraftThreadContext(draftId, { projectRef: scopeProjectRef(target.environmentId, target.projectId), environmentSelection: "manual", loadBalancedEnvironmentId: null, }); }, - [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], + [activeThreadRef, draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], ); const activeTerminalGroup = @@ -4313,11 +4445,78 @@ export default function ChatView(props: ChatViewProps) { ); }, []); - const supportsProjectSettingsOverrides = - environmentById.get(environmentId)?.serverConfig?.environment.capabilities - .projectSettingsOverrides === true; + const runSourceControlProjectScript = useCallback( + async (target: SourceControlProjectActionTarget, script: ProjectScript) => { + if (!activeThreadRef) return; + + setLastInvokedScriptByProjectId((current) => + current[target.projectId] === script.id + ? current + : { ...current, [target.projectId]: script.id }, + ); + const terminalId = `action-${randomUUID()}`; + const terminalTarget = { + environmentId: target.environmentId, + projectId: target.projectId, + cwd: target.cwd, + worktreePath: target.worktreePath, + label: `${script.name} · ${target.environmentLabel}`, + }; + useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId, terminalTarget); + setTerminalFocusRequestId((value) => value + 1); + + const runtimeEnv = projectScriptRuntimeEnv({ + project: { cwd: target.projectCwd }, + worktreePath: target.worktreePath, + }); + const openResult = await openTerminal({ + environmentId: target.environmentId, + input: { + threadId: activeThreadRef.threadId, + terminalId, + cwd: target.cwd, + ...(target.worktreePath !== null ? { worktreePath: target.worktreePath } : {}), + env: runtimeEnv, + cols: SCRIPT_TERMINAL_COLS, + rows: SCRIPT_TERMINAL_ROWS, + }, + }); + if (openResult._tag === "Failure") { + useRightPanelStore + .getState() + .closeSurface(activeThreadRef, terminalSurfaceId(terminalId, terminalTarget)); + if (!isAtomCommandInterrupted(openResult)) { + const error = squashAtomCommandFailure(openResult); + setThreadError( + activeThreadRef.threadId, + error instanceof Error ? error.message : `Failed to run action "${script.name}".`, + ); + } + return; + } + + const writeResult = await writeTerminal({ + environmentId: target.environmentId, + input: { + threadId: activeThreadRef.threadId, + terminalId, + data: `${script.command}\r`, + }, + }); + if (writeResult._tag === "Failure" && !isAtomCommandInterrupted(writeResult)) { + const error = squashAtomCommandFailure(writeResult); + setThreadError( + activeThreadRef.threadId, + error instanceof Error ? error.message : `Failed to run action "${script.name}".`, + ); + } + }, + [activeThreadRef, openTerminal, setLastInvokedScriptByProjectId, setThreadError, writeTerminal], + ); + const persistProjectScripts = useCallback( async (input: { + environmentId: EnvironmentId; projectId: ProjectId; projectCwd: string; previousScripts: ReadonlyArray; @@ -4327,24 +4526,14 @@ export default function ChatView(props: ChatViewProps) { }): Promise> => { const updateResult = mapAtomCommandResult( await updateProjectScriptSettings({ - environmentId, + environmentId: input.environmentId, input: { - // The canonical key on servers that understand it; the legacy - // per-project map is still translated on older ones. - patch: supportsProjectSettingsOverrides - ? { - projectSettingsOverrides: { - [input.projectId]: { - ...settings.projectSettingsOverrides[input.projectId], - defaultProjectScripts: input.nextScripts, - }, - }, - } - : { - projectScriptOverrides: { - [input.projectId]: input.nextScripts, - }, - }, + patch: buildSourceControlProjectScriptPatch({ + environmentId: input.environmentId, + projectId: input.projectId, + scripts: input.nextScripts, + environments: environmentById, + }), }, }), () => undefined, @@ -4361,7 +4550,7 @@ export default function ChatView(props: ChatViewProps) { if (isElectron && keybindingRule) { return mapAtomCommandResult( await upsertKeybinding({ - environmentId, + environmentId: input.environmentId, input: keybindingRule, }), () => undefined, @@ -4369,59 +4558,52 @@ export default function ChatView(props: ChatViewProps) { } return updateResult; }, - [ - environmentId, - settings.projectSettingsOverrides, - supportsProjectSettingsOverrides, - updateProjectScriptSettings, - upsertKeybinding, - ], + [environmentById, updateProjectScriptSettings, upsertKeybinding], ); - const saveProjectScript = useCallback( - async (input: NewProjectScriptInput): Promise> => { - if (!activeProject) { - return AsyncResult.success(undefined); - } + const saveProjectScriptForTarget = useCallback( + async ( + target: SourceControlProjectActionTarget, + input: NewProjectScriptInput, + ): Promise> => { const nextId = nextProjectScriptId( input.name, - activeProjectScripts.map((script) => script.id), + target.scripts.map((script) => script.id), ); const nextScript = buildProjectScript(nextId, input); const nextScripts = input.runOnWorktreeCreate ? [ - ...activeProjectScripts.map((script) => + ...target.scripts.map((script) => script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ), nextScript, ] - : [...activeProjectScripts, nextScript]; + : [...target.scripts, nextScript]; return persistProjectScripts({ - projectId: activeProject.id, - projectCwd: activeProject.workspaceRoot, - previousScripts: activeProjectScripts, + environmentId: target.environmentId, + projectId: target.projectId, + projectCwd: target.projectCwd, + previousScripts: target.scripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(nextId), }); }, - [activeProject, activeProjectScripts, persistProjectScripts], + [persistProjectScripts], ); - const updateProjectScript = useCallback( + const updateProjectScriptForTarget = useCallback( async ( + target: SourceControlProjectActionTarget, scriptId: string, input: NewProjectScriptInput, ): Promise> => { - if (!activeProject) { - return AsyncResult.success(undefined); - } - const existingScript = activeProjectScripts.find((script) => script.id === scriptId); + const existingScript = target.scripts.find((script) => script.id === scriptId); if (!existingScript) { return AsyncResult.failure(Cause.fail(new Error("Script not found."))); } const updatedScript = buildProjectScript(existingScript.id, input); - const nextScripts = activeProjectScripts.map((script) => + const nextScripts = target.scripts.map((script) => script.id === scriptId ? updatedScript : input.runOnWorktreeCreate @@ -4430,29 +4612,31 @@ export default function ChatView(props: ChatViewProps) { ); return persistProjectScripts({ - projectId: activeProject.id, - projectCwd: activeProject.workspaceRoot, - previousScripts: activeProjectScripts, + environmentId: target.environmentId, + projectId: target.projectId, + projectCwd: target.projectCwd, + previousScripts: target.scripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(scriptId), }); }, - [activeProject, activeProjectScripts, persistProjectScripts], + [persistProjectScripts], ); - const deleteProjectScript = useCallback( - async (scriptId: string): Promise> => { - if (!activeProject) { - return AsyncResult.success(undefined); - } - const nextScripts = activeProjectScripts.filter((script) => script.id !== scriptId); + const deleteProjectScriptForTarget = useCallback( + async ( + target: SourceControlProjectActionTarget, + scriptId: string, + ): Promise> => { + const nextScripts = target.scripts.filter((script) => script.id !== scriptId); - const deletedName = activeProjectScripts.find((s) => s.id === scriptId)?.name; + const deletedName = target.scripts.find((script) => script.id === scriptId)?.name; const result = await persistProjectScripts({ - projectId: activeProject.id, - projectCwd: activeProject.workspaceRoot, - previousScripts: activeProjectScripts, + environmentId: target.environmentId, + projectId: target.projectId, + projectCwd: target.projectCwd, + previousScripts: target.scripts, nextScripts, keybinding: null, keybindingCommand: commandForProjectScript(scriptId), @@ -4474,7 +4658,50 @@ export default function ChatView(props: ChatViewProps) { } return result; }, - [activeProject, activeProjectScripts, persistProjectScripts], + [persistProjectScripts], + ); + const activeProjectActionTarget = useMemo( + () => + activeProject + ? { + environmentId, + environmentLabel: activeEnvironment?.label ?? environmentId, + projectId: activeProject.id, + cwd: gitCwd ?? activeProject.workspaceRoot, + projectCwd: activeProject.workspaceRoot, + worktreePath: activeThreadWorktreePath, + scripts: activeProjectScripts, + } + : null, + [ + activeEnvironment?.label, + activeProject, + activeProjectScripts, + activeThreadWorktreePath, + environmentId, + gitCwd, + ], + ); + const saveProjectScript = useCallback( + (input: NewProjectScriptInput): Promise> => + activeProjectActionTarget + ? saveProjectScriptForTarget(activeProjectActionTarget, input) + : Promise.resolve(AsyncResult.success(undefined)), + [activeProjectActionTarget, saveProjectScriptForTarget], + ); + const updateProjectScript = useCallback( + (scriptId: string, input: NewProjectScriptInput): Promise> => + activeProjectActionTarget + ? updateProjectScriptForTarget(activeProjectActionTarget, scriptId, input) + : Promise.resolve(AsyncResult.success(undefined)), + [activeProjectActionTarget, updateProjectScriptForTarget], + ); + const deleteProjectScript = useCallback( + (scriptId: string): Promise> => + activeProjectActionTarget + ? deleteProjectScriptForTarget(activeProjectActionTarget, scriptId) + : Promise.resolve(AsyncResult.success(undefined)), + [activeProjectActionTarget, deleteProjectScriptForTarget], ); const handleRuntimeModeChange = useCallback( @@ -4558,6 +4785,24 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); + + const dismissThreadError = useCallback(() => { + const action = resolveThreadErrorDismissAction(threadErrorSource); + if (action === "clear-thread") { + setThreadError(activeThreadId, null); + } else if (action === "clear-source-control") { + clearActiveSourceControlMetadataError(); + } else { + dismissThreadErrorBannerForSession(threadErrorBannerKey); + setThreadErrorBannerDismissTick((tick) => tick + 1); + } + }, [ + activeThreadId, + clearActiveSourceControlMetadataError, + setThreadError, + threadErrorBannerKey, + threadErrorSource, + ]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -4665,9 +4910,11 @@ export default function ChatView(props: ChatViewProps) { const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); + useRightPanelStore + .getState() + .openFile(activeThreadRef, relativePath, undefined, activeFileSurface?.cwd); }, - [activeProject, activeThreadRef], + [activeFileSurface?.cwd, activeProject, activeThreadRef], ); // The shell carries server PR updates even while thread detail is still loading. const activeThreadMetadata = activeThreadShell ?? activeThread; @@ -4885,31 +5132,52 @@ export default function ChatView(props: ChatViewProps) { createBrowserSurface, previewPanelOpen, ]); + const activePanelTerminalTarget = + activeRightPanelSurface?.kind === "terminal" ? activeRightPanelSurface.target : undefined; + const activePanelTerminalProject = activePanelTerminalTarget + ? (allProjects.find( + (project) => + project.environmentId === activePanelTerminalTarget.environmentId && + project.id === activePanelTerminalTarget.projectId, + ) ?? null) + : activeProject; const addTerminalSurface = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) return; - const cwd = gitCwd ?? activeProject.workspaceRoot; - const terminalId = nextTerminalId(allocatableActiveTerminalIds); - useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); + if (!activeThreadRef || !activeThreadId || !activePanelTerminalProject) return; + const cwd = + activePanelTerminalTarget?.cwd ?? gitCwd ?? activePanelTerminalProject.workspaceRoot; + const worktreePath = activePanelTerminalTarget + ? activePanelTerminalTarget.worktreePath + : activeThreadWorktreePath; + const terminalId = nextTerminalId( + activePanelTerminalTarget ? allocatablePanelTerminalIds : allocatableActiveTerminalIds, + ); + useRightPanelStore + .getState() + .openTerminal(activeThreadRef, terminalId, activePanelTerminalTarget); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ - environmentId: activeThreadRef.environmentId, + environmentId: + (activePanelTerminalTarget?.environmentId as EnvironmentId | undefined) ?? + activeThreadRef.environmentId, input: { threadId: activeThreadId, terminalId, cwd, - ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + ...(worktreePath != null ? { worktreePath } : {}), env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.workspaceRoot }, - worktreePath: activeThreadWorktreePath, + project: { cwd: activePanelTerminalProject.workspaceRoot }, + worktreePath, }), }, }); }, [ - activeProject, + activePanelTerminalProject, + activePanelTerminalTarget, activeThreadId, activeThreadRef, activeThreadWorktreePath, allocatableActiveTerminalIds, + allocatablePanelTerminalIds, gitCwd, openTerminal, ]); @@ -4918,39 +5186,49 @@ export default function ChatView(props: ChatViewProps) { if ( !activeThreadRef || !activeThreadId || - !activeProject || + !activePanelTerminalProject || activeRightPanelSurface?.kind !== "terminal" || activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP ) { return; } - const terminalId = nextTerminalId(allocatableActiveTerminalIds); - const cwd = gitCwd ?? activeProject.workspaceRoot; + const terminalId = nextTerminalId( + activePanelTerminalTarget ? allocatablePanelTerminalIds : allocatableActiveTerminalIds, + ); + const cwd = + activePanelTerminalTarget?.cwd ?? gitCwd ?? activePanelTerminalProject.workspaceRoot; + const worktreePath = activePanelTerminalTarget + ? activePanelTerminalTarget.worktreePath + : activeThreadWorktreePath; useRightPanelStore .getState() .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ - environmentId: activeThreadRef.environmentId, + environmentId: + (activePanelTerminalTarget?.environmentId as EnvironmentId | undefined) ?? + activeThreadRef.environmentId, input: { threadId: activeThreadId, terminalId, cwd, - ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + ...(worktreePath != null ? { worktreePath } : {}), env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.workspaceRoot }, - worktreePath: activeThreadWorktreePath, + project: { cwd: activePanelTerminalProject.workspaceRoot }, + worktreePath, }), }, }); }, [ - activeProject, + activePanelTerminalProject, + activePanelTerminalTarget, activeRightPanelSurface, activeThreadId, activeThreadRef, activeThreadWorktreePath, allocatableActiveTerminalIds, + allocatablePanelTerminalIds, gitCwd, openTerminal, ], @@ -4971,11 +5249,17 @@ export default function ChatView(props: ChatViewProps) { const closePanelTerminal = useCallback( (terminalId: string) => { if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + const terminalThreadRef = activeRightPanelSurface.target + ? scopeThreadRef( + activeRightPanelSurface.target.environmentId as EnvironmentId, + activeThreadRef.threadId, + ) + : activeThreadRef; void closeTerminalMutation({ - environmentId: activeThreadRef.environmentId, + environmentId: terminalThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, }); - storeCloseTerminal(activeThreadRef, terminalId); + storeCloseTerminal(terminalThreadRef, terminalId); useRightPanelStore .getState() .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); @@ -4994,12 +5278,17 @@ export default function ChatView(props: ChatViewProps) { ); const requestClosePanelTerminal = useCallback( (terminalId: string) => { - const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); + const label = + (activeRightPanelSurface?.kind === "terminal" + ? activeRightPanelSurface.target?.label + : undefined) ?? + activeTerminalLabelsById.get(terminalId) ?? + getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { if (confirmed) closePanelTerminal(terminalId); }); }, - [activeTerminalLabelsById, closePanelTerminal], + [activeRightPanelSurface, activeTerminalLabelsById, closePanelTerminal], ); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { @@ -5044,11 +5333,17 @@ export default function ChatView(props: ChatViewProps) { }); } if (surface.kind === "terminal") { + const terminalThreadRef = surface.target + ? scopeThreadRef( + surface.target.environmentId as EnvironmentId, + activeThreadRef.threadId, + ) + : activeThreadRef; for (const terminalId of surface.terminalIds) { - storeCloseTerminal(activeThreadRef, terminalId); + storeCloseTerminal(terminalThreadRef, terminalId); void closeTerminalMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, + environmentId: terminalThreadRef.environmentId, + input: { threadId: terminalThreadRef.threadId, terminalId, deleteHistory: true }, }); } } @@ -5118,6 +5413,7 @@ export default function ChatView(props: ChatViewProps) { return; } const activeLabel = + surface.target?.label ?? activeTerminalLabelsById.get(surface.activeTerminalId) ?? getTerminalLabel(surface.activeTerminalId); const otherLabels = surface.terminalIds @@ -6242,6 +6538,7 @@ export default function ChatView(props: ChatViewProps) { const activeBackgroundLiveness = !isWorking && activeThread ? (activeThreadShell?.backgroundLiveness ?? null) : null; const [isStoppingBackgroundWork, setIsStoppingBackgroundWork] = useState(false); + const backgroundStopRequestSequenceRef = useRef(0); useEffect(() => { // "Stopping..." holds until the liveness clears; the interrupt command // returning only means the request was accepted. @@ -6252,10 +6549,12 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { // Per-thread state: switching threads while A's stop is pending must not // disable B's Stop button (review finding). + backgroundStopRequestSequenceRef.current += 1; setIsStoppingBackgroundWork(false); - }, [activeThreadId]); + }, [activeThreadKey]); const handleStopBackgroundWork = useCallback(async () => { if (!activeThread) return; + const requestSequence = ++backgroundStopRequestSequenceRef.current; setIsStoppingBackgroundWork(true); const result = await interruptThreadTurn({ environmentId, @@ -6264,8 +6563,15 @@ export default function ChatView(props: ChatViewProps) { if (result._tag === "Failure") { // Every failure clears the pending state — an interrupted command // never reached the server, so liveness would hold "Stopping..." - // forever. Only real failures toast. - setIsStoppingBackgroundWork(false); + // forever. A stale completion cannot clear a newer thread's request. + if ( + isLatestRequestSequence({ + currentSequence: backgroundStopRequestSequenceRef.current, + requestSequence, + }) + ) { + setIsStoppingBackgroundWork(false); + } if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); setThreadError( @@ -9576,6 +9882,23 @@ export default function ChatView(props: ChatViewProps) { workspaceMutationId={workspaceMutationId} /> + ) : sourceControlPanelTarget ? ( + + + ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( @@ -9633,8 +9956,8 @@ export default function ChatView(props: ChatViewProps) { ) : renderedRightPanelSurface?.kind === "agents" ? ( ) : renderedRightPanelSurface?.kind === "device" ? ( @@ -9659,10 +9982,14 @@ export default function ChatView(props: ChatViewProps) { key={`${activeThread.environmentId}:${ renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment ? `attachment:${renderedRightPanelSurface.attachment.id}` - : activeWorkspaceRoot + : (activeFileSurface?.cwd ?? activeWorkspaceRoot) }`} - environmentId={activeThread.environmentId} - cwd={activeWorkspaceRoot ?? ""} + environmentId={ + renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment + ? activeThread.environmentId + : (activeProject?.environmentId ?? activeThread.environmentId) + } + cwd={activeFileSurface?.cwd ?? activeWorkspaceRoot ?? ""} projectName={activeProject?.title ?? ""} threadRef={activeThreadRef} composerDraftTarget={composerDraftTarget} @@ -9816,11 +10143,7 @@ export default function ChatView(props: ChatViewProps) { { - setThreadError(activeThread.id, null); - dismissThreadErrorBannerForSession(threadErrorBannerKey); - setThreadErrorBannerDismissTick((tick) => tick + 1); - }} + onDismiss={dismissThreadError} /> {/* Messages Wrapper */} @@ -10289,6 +10612,7 @@ export default function ChatView(props: ChatViewProps) { onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} + onAddSourceControl={addSourceControlSurface} onAddPullRequest={addPullRequestSurface} onAddPullRequests={addPullRequestsSurface} onAddAgents={addAgentsSurface} @@ -10297,6 +10621,7 @@ export default function ChatView(props: ChatViewProps) { terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + sourceControlAvailable={sourceControlAvailable} pullRequestAvailable={pullRequestSurfaceAvailable} pullRequestsAvailable={pullRequestsSurfaceAvailable} agentsAvailable @@ -10346,6 +10671,7 @@ export default function ChatView(props: ChatViewProps) { onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} + onAddSourceControl={addSourceControlSurface} onAddPullRequest={addPullRequestSurface} onAddPullRequests={addPullRequestsSurface} onAddAgents={addAgentsSurface} @@ -10354,6 +10680,7 @@ export default function ChatView(props: ChatViewProps) { terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + sourceControlAvailable={sourceControlAvailable} pullRequestAvailable={pullRequestSurfaceAvailable} pullRequestsAvailable={pullRequestsSurfaceAvailable} agentsAvailable diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2c26f1e69b1d..457afe664403 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -414,7 +414,7 @@ interface PublishRepositoryDialogProps { readonly gitCwd: string; } -function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { +export function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const openLink = useOpenLink(props.threadRef); const navigate = useNavigate(); const sourceControlDiscovery = useEnvironmentQuery( diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index dfaaf171c429..cc9fc5ae4409 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -1,16 +1,16 @@ import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { RightPanelTabs, resolvePullRequestTabLink, shouldOpenDefaultBrowserProfileFromMenuClick, - surfaceShortcutActionForKey, surfaceShortcutTargetsTypingContext, - tabMuteMenuItem, } from "./RightPanelTabs"; +import { browserTabMuteMenuItem } from "./rightPanelBrowserTabState"; +import { buildAddSurfaceActions, surfaceShortcutActionForKey } from "./rightPanelSurfaceActions"; describe("browser profile submenu", () => { it("reserves touch clicks for opening the choices while mouse clicks use the default", () => { @@ -118,6 +118,7 @@ function renderTabs( onAddBrowser={() => undefined} onAddBrowserInProfile={() => undefined} onAddTerminal={() => undefined} + onAddSourceControl={() => undefined} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} onAddDiff={() => undefined} @@ -129,6 +130,7 @@ function renderTabs( terminalAvailable={false} diffAvailable={false} filesAvailable={false} + sourceControlAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} agentsAvailable={false} @@ -162,28 +164,101 @@ describe("RightPanelTabs preview favicon", () => { }); }); -describe("surface shortcuts", () => { - const actions = [ - { shortcut: "B", available: true, label: "Browser" }, - { shortcut: "D", available: false, label: "Diff" }, - ] as const; +function actionProps() { + return { + onAddBrowser: vi.fn(), + onAddTerminal: vi.fn(), + onAddDiff: vi.fn(), + onAddFiles: vi.fn(), + onAddSourceControl: vi.fn(), + onAddPullRequest: vi.fn(), + onAddAgents: vi.fn(), + onAddPullRequests: vi.fn(), + onAddDevice: vi.fn(), + browserAvailable: true, + terminalAvailable: true, + diffAvailable: false, + filesAvailable: true, + sourceControlAvailable: true, + pullRequestAvailable: true, + agentsAvailable: true, + pullRequestsAvailable: true, + deviceAvailable: true, + liveAgentCount: 3, + }; +} + +describe("RightPanelTabs add-surface actions", () => { + it("keeps Version Control first and unique in the empty state", () => { + const actions = buildAddSurfaceActions(actionProps(), "empty-state"); + const sourceControlActions = actions.filter((action) => action.id === "source-control"); + + expect(actions[0]?.id).toBe("source-control"); + expect(actions[0]?.instancePolicy).toBe("singleton"); + expect(sourceControlActions).toHaveLength(1); + expect(actions.some((action) => action.id === "pull-request")).toBe(true); + }); + + it("keeps Version Control last and unique in the add menu", () => { + const actions = buildAddSurfaceActions(actionProps(), "menu"); + const sourceControlActions = actions.filter((action) => action.id === "source-control"); + + expect(actions.at(-1)?.id).toBe("source-control"); + expect(actions.find((action) => action.id === "pull-request")?.instancePolicy).toBe("multiple"); + expect(sourceControlActions).toHaveLength(1); + expect(actions.some((action) => action.id === "pull-request")).toBe(true); + }); + + it("uses the Version Control callback, availability, and disabled reason", () => { + const props = actionProps(); + const actions = buildAddSurfaceActions(props); + const sourceControl = actions.find((action) => action.id === "source-control"); + const unavailableSourceControl = buildAddSurfaceActions({ + ...props, + sourceControlAvailable: false, + }).find((action) => action.id === "source-control"); + + expect(sourceControl?.available).toBe(true); + sourceControl?.onClick(); + expect(props.onAddSourceControl).toHaveBeenCalledTimes(1); + expect(unavailableSourceControl?.available).toBe(false); + expect(unavailableSourceControl?.disabledReason).toBe( + "Version Control is only available when a project is open in a Git repository.", + ); + }); + it("preserves the live agent badge in the merged action model", () => { + const actions = buildAddSurfaceActions(actionProps()); + + expect(actions.find((action) => action.id === "agents")?.badgeCount).toBe(3); + expect(actions.find((action) => action.id === "source-control")?.badgeCount).toBe(0); + }); +}); + +describe("surface shortcuts", () => { it("matches available surface shortcuts case-insensitively", () => { - expect(surfaceShortcutActionForKey(actions, shortcutEvent("b"))).toBe(actions[0]); - expect(surfaceShortcutActionForKey(actions, shortcutEvent("B"))).toBe(actions[0]); + const actions = buildAddSurfaceActions(actionProps(), "menu"); + const sourceControl = actions.find((action) => action.id === "source-control"); + + expect(surfaceShortcutActionForKey(actions, shortcutEvent("v"))).toBe(sourceControl); + expect(surfaceShortcutActionForKey(actions, shortcutEvent("V"))).toBe(sourceControl); }); it("does not activate unavailable surfaces", () => { + const actions = buildAddSurfaceActions({ ...actionProps(), diffAvailable: false }, "menu"); + expect(surfaceShortcutActionForKey(actions, shortcutEvent("d"))).toBeNull(); }); it("leaves modified, composing, and already-handled key events alone", () => { - expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { metaKey: true }))).toBeNull(); + const actions = buildAddSurfaceActions(actionProps(), "menu"); + + expect(surfaceShortcutActionForKey(actions, shortcutEvent("v", { metaKey: true }))).toBeNull(); expect( - surfaceShortcutActionForKey(actions, shortcutEvent("b", { isComposing: true })), + surfaceShortcutActionForKey(actions, shortcutEvent("v", { isComposing: true })), ).toBeNull(); expect( - surfaceShortcutActionForKey(actions, shortcutEvent("b", { defaultPrevented: true })), + surfaceShortcutActionForKey(actions, shortcutEvent("v", { defaultPrevented: true })), ).toBeNull(); }); }); @@ -253,32 +328,35 @@ describe("RightPanelTabs audio indicator", () => { }); }); -describe("tabMuteMenuItem", () => { +describe("browserTabMuteMenuItem", () => { const overlay = (audioMuted: boolean) => - ({ audioMuted, audible: false }) as Parameters[0]["overlay"]; + ({ audioMuted, audible: false }) as Parameters[0]["overlay"]; it("stays disabled until the desktop tab exists", () => { // The server session id resolves before the preview manager finishes // createTab. Muting in that window fails with an error nobody surfaces. - expect(tabMuteMenuItem({ overlay: null, canResolveRuntimeTabId: true })).toEqual({ + expect(browserTabMuteMenuItem({ overlay: null, canResolveRuntimeTabId: true })).toEqual({ label: "Mute tab", disabled: true, }); }); it("stays disabled when no runtime tab id can be resolved", () => { - expect(tabMuteMenuItem({ overlay: overlay(false), canResolveRuntimeTabId: false })).toEqual({ - label: "Mute tab", - disabled: true, - }); + expect( + browserTabMuteMenuItem({ overlay: overlay(false), canResolveRuntimeTabId: false }), + ).toEqual({ label: "Mute tab", disabled: true }); }); it("offers mute and unmute once the tab is addressable", () => { - expect(tabMuteMenuItem({ overlay: overlay(false), canResolveRuntimeTabId: true })).toEqual({ + expect( + browserTabMuteMenuItem({ overlay: overlay(false), canResolveRuntimeTabId: true }), + ).toEqual({ label: "Mute tab", disabled: false, }); - expect(tabMuteMenuItem({ overlay: overlay(true), canResolveRuntimeTabId: true })).toEqual({ + expect( + browserTabMuteMenuItem({ overlay: overlay(true), canResolveRuntimeTabId: true }), + ).toEqual({ label: "Unmute tab", disabled: false, }); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 001342e48262..13801ef6b5b8 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -21,6 +21,7 @@ import { ChevronRight, FileDiff, Files, + GitBranch, Globe2, Plus, TerminalSquare, @@ -74,8 +75,17 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; import { FaviconImage } from "./preview/PreviewFaviconIcon"; -import { previewBridge } from "./preview/previewBridge"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { + browserTabMuteMenuItem, + resolveBrowserTabRuntimeState, + setBrowserTabAudioMuted, +} from "./rightPanelBrowserTabState"; +import { + type AddPanelSurfaceAction, + buildAddSurfaceActions, + surfaceShortcutActionForKey, +} from "./rightPanelSurfaceActions"; import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; @@ -119,6 +129,7 @@ interface RightPanelTabsProps { onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; + onAddSourceControl: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; onAddAgents: () => void; @@ -127,6 +138,7 @@ interface RightPanelTabsProps { terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; + sourceControlAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; agentsAvailable: boolean; @@ -152,18 +164,6 @@ export function shouldOpenDefaultBrowserProfileFromMenuClick( ): boolean { return pointerType !== "touch"; } - -const SURFACE_DISABLED_REASONS = { - browser: "Browser previews are only available in the T3 Code desktop app.", - terminal: "Terminal surfaces are only available from a project thread.", - files: "Files are only available when a project is open.", - diff: "Diff is only available for server threads in Git repositories.", - pullRequest: "This thread's branch has no pull request yet.", - pullRequests: "No linked pull requests are available for this thread.", - agents: "Agents are only available from a thread.", - device: "Devices are only available from a thread.", -} as const; - /** Overlays that must win over the launcher's letter shortcuts. */ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ '[data-slot="dialog-popup"]', @@ -176,18 +176,6 @@ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ '[data-slot="autocomplete-popup"]', ].join(","); -/** One-line unavailability hints for the empty-state rows. */ -const SURFACE_UNAVAILABLE_HINTS = { - browser: "Only available in the desktop app.", - terminal: "Available when a project is open.", - files: "Available when a project is open.", - diff: "Available for Git repositories.", - pullRequest: "No pull request on this branch yet.", - pullRequests: "No linked pull requests available.", - agents: "Available from a thread.", - device: "Available from a thread.", -} as const; - type TabContextMenuAction = | "rename" | "copy-path" @@ -203,63 +191,6 @@ function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; } -/** - * Desktop preview tab backing a surface, or null for non-preview surfaces, the - * "new browser tab" placeholder, and the web build where no desktop tab exists. - */ -function previewTabIdOf( - surface: RightPanelSurface, - sessions: Readonly>, -): string | null { - if (surface.kind !== "preview" || !surface.resourceId) return null; - return sessions[surface.resourceId]?.tabId ?? null; -} - -/** - * Label and enabled state for a preview tab's mute menu entry. - * Stays disabled until desktop overlay state arrives: a server session id can - * resolve while the preview manager's createTab is still in flight, and muting - * then fails with a PreviewTabNotFoundError nothing surfaces to the user. - */ -export function tabMuteMenuItem(input: { - overlay: DesktopPreviewOverlay | null; - canResolveRuntimeTabId: boolean; -}): { label: string; disabled: boolean } { - const muted = input.overlay?.audioMuted ?? false; - return { - label: muted ? "Unmute tab" : "Mute tab", - disabled: input.overlay === null || !input.canResolveRuntimeTabId, - }; -} - -type TabAudioState = "none" | "audible" | "muted"; - -/** - * A muted tab that is not making sound shows nothing: mute is armed silently, - * and the indicator only appears once there is audio to speak of. - */ -function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { - if (!overlay?.audible) return "none"; - return overlay.audioMuted ? "muted" : "audible"; -} - -type SurfaceShortcutEvent = Pick< - KeyboardEvent, - "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" ->; - -export function surfaceShortcutActionForKey< - const Action extends { available: boolean; shortcut: string }, ->(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { - if (event.defaultPrevented || event.isComposing) return null; - if (event.metaKey || event.ctrlKey || event.altKey) return null; - return ( - actions.find( - (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(), - ) ?? null - ); -} - /** * A focused editable is a typing context whether or not it has text yet: an * empty chat composer at rest is still where the user's next keystrokes are @@ -288,7 +219,7 @@ function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) function SurfaceMenuItem(props: { available: boolean; - disabledReason?: string; + disabledReason: string | undefined; shortcut: string; onClick: () => void; children: ReactNode; @@ -316,106 +247,16 @@ function SurfaceMenuItem(props: { * surfaces stay visible with a one-line reason. */ function RightPanelEmptyState(props: { - onAddBrowser: () => void; + actions: readonly AddPanelSurfaceAction[]; onAddBrowserInProfile: (profileId: string) => void; browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; - onAddTerminal: () => void; - onAddDiff: () => void; - onAddFiles: () => void; - onAddPullRequest: () => void; - onAddPullRequests: () => void; - onAddAgents: () => void; - onAddDevice: () => void; - browserAvailable: boolean; - terminalAvailable: boolean; - diffAvailable: boolean; - filesAvailable: boolean; - pullRequestAvailable: boolean; - pullRequestsAvailable: boolean; - agentsAvailable: boolean; - deviceAvailable: boolean; - liveAgentCount: number; }) { // -1 means no highlight: it only appears on hover or arrow use. const [highlight, setHighlight] = useState(-1); - const actions = [ - { - label: "Browser", - icon: Globe2, - shortcut: "B", - available: props.browserAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, - onClick: props.onAddBrowser, - badgeCount: 0, - }, - { - label: "Terminal", - icon: TerminalSquare, - shortcut: "T", - available: props.terminalAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, - onClick: props.onAddTerminal, - badgeCount: 0, - }, - { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.files, - onClick: props.onAddFiles, - badgeCount: 0, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, - onClick: props.onAddDiff, - badgeCount: 0, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, - shortcut: "P", - available: props.pullRequestAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, - onClick: props.onAddPullRequest, - badgeCount: 0, - }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, - onClick: props.onAddPullRequests, - badgeCount: 0, - }, - { - label: "Agents", - icon: Bot, - shortcut: "A", - available: props.agentsAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.agents, - onClick: props.onAddAgents, - badgeCount: props.liveAgentCount, - }, - { - label: "Device", - description: "Watch an iOS Simulator or Android Emulator.", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.device, - onClick: props.onAddDevice, - badgeCount: 0, - }, - ] as const; + const actions = props.actions; - type SurfaceAction = (typeof actions)[number]; + type SurfaceAction = AddPanelSurfaceAction; const availableActions = actions.filter((action) => action.available); const highlightIndex = @@ -621,9 +462,12 @@ function surfaceTitle( ); case "terminal": return ( + surface.target?.label ?? terminalLabelsById.get(surface.activeTerminalId) ?? getTerminalLabel(surface.activeTerminalId) ); + case "source-control": + return "Version Control"; case "pull-request": return `#${surface.number}`; case "pull-requests": @@ -703,6 +547,8 @@ function SurfaceIcon({ ); case "terminal": return ; + case "source-control": + return ; case "pull-request": return ( (null); const [renamingDevice, setRenamingDevice] = useState(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); + const [emptyStateActions, menuActions] = useMemo(() => { + const actionProps = { + browserAvailable: props.browserAvailable, + diffAvailable: props.diffAvailable, + filesAvailable: props.filesAvailable, + terminalAvailable: props.terminalAvailable, + sourceControlAvailable: props.sourceControlAvailable, + pullRequestAvailable: props.pullRequestAvailable, + onAddPullRequests: props.onAddPullRequests, + onAddDevice: props.onAddDevice, + pullRequestsAvailable: props.pullRequestsAvailable, + deviceAvailable: props.deviceAvailable, + agentsAvailable: props.agentsAvailable, + liveAgentCount: props.liveAgentCount, + onAddBrowser: props.onAddBrowser, + onAddDiff: props.onAddDiff, + onAddFiles: props.onAddFiles, + onAddAgents: props.onAddAgents, + onAddPullRequest: props.onAddPullRequest, + onAddSourceControl: props.onAddSourceControl, + onAddTerminal: props.onAddTerminal, + }; + return [ + buildAddSurfaceActions(actionProps, "empty-state"), + buildAddSurfaceActions(actionProps, "menu"), + ] as const; + }, [ + props.onAddPullRequests, + props.onAddDevice, + props.pullRequestsAvailable, + props.deviceAvailable, + props.agentsAvailable, + props.browserAvailable, + props.diffAvailable, + props.filesAvailable, + props.liveAgentCount, + props.onAddAgents, + props.onAddBrowser, + props.onAddDiff, + props.onAddFiles, + props.onAddPullRequest, + props.onAddSourceControl, + props.onAddTerminal, + props.pullRequestAvailable, + props.sourceControlAvailable, + props.terminalAvailable, + ]); const [tabScrollState, setTabScrollState] = useState({ hasOverflow: false, canScrollLeft: false, @@ -868,75 +761,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }); }, []); - const addSurfaceActions = [ - { - label: "Browser", - icon: Globe2, - shortcut: "B", - available: props.browserAvailable, - disabledReason: SURFACE_DISABLED_REASONS.browser, - onClick: props.onAddBrowser, - }, - { - label: "Terminal", - icon: TerminalSquare, - shortcut: "T", - available: props.terminalAvailable, - disabledReason: SURFACE_DISABLED_REASONS.terminal, - onClick: props.onAddTerminal, - }, - { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_DISABLED_REASONS.files, - onClick: props.onAddFiles, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_DISABLED_REASONS.diff, - onClick: props.onAddDiff, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, - shortcut: "P", - available: props.pullRequestAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequest, - onClick: props.onAddPullRequest, - }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequests, - onClick: props.onAddPullRequests, - }, - { - label: "Agents", - icon: Bot, - shortcut: "A", - available: props.agentsAvailable, - disabledReason: SURFACE_DISABLED_REASONS.agents, - onClick: props.onAddAgents, - }, - { - label: "Device", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_DISABLED_REASONS.device, - onClick: props.onAddDevice, - }, - ] as const; - const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { - const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); + const action = surfaceShortcutActionForKey(menuActions, event.nativeEvent); if (!action) return; event.preventDefault(); event.stopPropagation(); @@ -961,21 +787,23 @@ export function RightPanelTabs(props: RightPanelTabsProps) { if (surface.kind === "file" && surface.attachment === undefined) { items.push({ id: "copy-path", label: "Copy path" }); } - const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions); + const browserTab = resolveBrowserTabRuntimeState({ + surface, + sessions: props.previewSessions, + desktopByTabId: props.desktopByTabId, + previewRuntimeTabId: props.previewRuntimeTabId, + }); // Desktop overlay state only arrives once the preview manager has created // the tab. A server session id alone can still be ahead of that, and // muting then fails with PreviewTabNotFoundError that nobody surfaces. - const menuOverlay = menuPreviewTabId - ? (props.desktopByTabId[menuPreviewTabId] ?? null) - : null; - const menuMuted = menuOverlay?.audioMuted ?? false; + const menuMuted = browserTab.overlay?.audioMuted ?? false; if (surface.kind === "preview") { // Not gated on audibility: silencing a quiet tab ahead of time is the // point, so the item is offered whenever the tab is mutable at all. items.push({ id: "toggle-mute", - ...tabMuteMenuItem({ - overlay: menuOverlay, + ...browserTabMuteMenuItem({ + overlay: browserTab.overlay, canResolveRuntimeTabId: props.previewRuntimeTabId !== undefined, }), }); @@ -1010,14 +838,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { } break; case "toggle-mute": { - // menuOverlay repeats the disabled gate above: the desktop tab must + // The overlay repeats the disabled gate above: the desktop tab must // exist before it can be addressed, however the menu was dismissed. - const runtimeTabId = - menuPreviewTabId && menuOverlay - ? (props.previewRuntimeTabId?.(menuPreviewTabId) ?? null) - : null; - if (runtimeTabId) { - void previewBridge?.setAudioMuted(runtimeTabId, !menuMuted).catch(() => undefined); + if (browserTab.runtimeTabId && browserTab.overlay) { + setBrowserTabAudioMuted(browserTab.runtimeTabId, !menuMuted); } break; } @@ -1132,15 +956,13 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const active = surface.id === props.activeSurfaceId; const pending = props.pendingSurfaceIds.has(surface.id); const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); - const previewTabId = previewTabIdOf(surface, props.previewSessions); - // Desktop state is keyed by the session id, but desktop actions - // must be addressed with the runtime id. - const audio = tabAudioState( - previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, - ); - const audioRuntimeTabId = previewTabId - ? (props.previewRuntimeTabId?.(previewTabId) ?? null) - : null; + const browserTab = resolveBrowserTabRuntimeState({ + surface, + sessions: props.previewSessions, + desktopByTabId: props.desktopByTabId, + previewRuntimeTabId: props.previewRuntimeTabId, + }); + const audioRuntimeTabId = browserTab.runtimeTabId; return (
) : null} - {audio === "none" || !audioRuntimeTabId ? null : ( + {browserTab.audio === "none" || !audioRuntimeTabId ? null : ( { // Sibling of the close button, inside a tab that // activates on click: keep this to the toggle. event.stopPropagation(); - void previewBridge - ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") - .catch(() => undefined); + setBrowserTabAudioMuted( + audioRuntimeTabId, + browserTab.audio !== "muted", + ); }} > - {audio === "muted" ? ( + {browserTab.audio === "muted" ? ( ) : ( @@ -1200,7 +1025,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { } /> - {audio === "muted" ? "Unmute tab" : "Mute tab"} + + {browserTab.audio === "muted" ? "Unmute tab" : "Mute tab"} + )} {renamingDevice === surface.id ? ( @@ -1278,7 +1105,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { sideOffset={6} onKeyDownCapture={handleAddSurfaceMenuKeyDown} > - {addSurfaceActions.map((action) => { + {menuActions.map((action) => { const Icon = action.icon; // Browser collapses into one row: clicking the trigger opens // the default profile (the common case stays one click), @@ -1331,7 +1158,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { } return ( {props.activeSurfaceId === null ? ( ) : ( props.children diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 29849561efe2..6d870f891259 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { WorkingIndicator } from "./WorkingIndicator"; import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; @@ -51,7 +52,6 @@ import { ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, - CircleDashedIcon, ClockIcon, EyeIcon, FolderIcon, @@ -242,8 +242,14 @@ import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "./ui/menu"; -import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { MiddleTruncate } from "./ui/middle-truncate"; +import { + Tooltip, + TooltipCardPopup, + TooltipPopup, + TooltipProvider, + TooltipTrigger, +} from "./ui/tooltip"; import { composerDraftHasUserContent, DraftId, @@ -384,8 +390,12 @@ function SidebarThreadTooltip({ const driverKind = providerEntry?.driverKind ?? null; const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); return ( - - {/* The viewport's own inset (py-1 px-2) plus this one make the floating inset. */} +
{thread.title} @@ -465,7 +475,7 @@ function SidebarThreadTooltip({
) : null}
-
+ ); } @@ -1185,7 +1195,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { icon: "working" as const, // No shimmer: a label that animates forever is noise in a sidebar // full of them (and repaints every vsync on high-refresh displays). - className: "text-sky-600 dark:text-sky-400", + className: "text-working-foreground", } : status === "monitoring" ? { @@ -1893,7 +1903,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} > {topStatus.icon === "working" ? ( - + ) : topStatus.icon === "input" ? ( ) : topStatus.icon === "approval" ? ( @@ -1908,7 +1918,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {/* The label alone is the live region: a role="status" wrapper around the ticking duration would make screen readers announce every second. */} - {topStatus.label} + {topStatus.icon !== "working" ? ( + {topStatus.label} + ) : null} {status === "working" ? ( diff --git a/apps/web/src/components/WorkingIndicator.tsx b/apps/web/src/components/WorkingIndicator.tsx new file mode 100644 index 000000000000..05488fecdb26 --- /dev/null +++ b/apps/web/src/components/WorkingIndicator.tsx @@ -0,0 +1,10 @@ +import { CircleDashedIcon } from "lucide-react"; + +export function WorkingIndicator({ label }: { readonly label: string }) { + return ( + + + {label} + + ); +} diff --git a/apps/web/src/components/rightPanelBrowserTabState.ts b/apps/web/src/components/rightPanelBrowserTabState.ts new file mode 100644 index 000000000000..2c3f1b634551 --- /dev/null +++ b/apps/web/src/components/rightPanelBrowserTabState.ts @@ -0,0 +1,51 @@ +import type { PreviewSessionSnapshot } from "@t3tools/contracts"; + +import type { DesktopPreviewOverlay } from "~/previewStateStore"; +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { previewBridge } from "./preview/previewBridge"; + +export type BrowserTabAudioState = "none" | "audible" | "muted"; + +export interface BrowserTabRuntimeState { + readonly sessionTabId: string | null; + readonly runtimeTabId: string | null; + readonly overlay: DesktopPreviewOverlay | null; + readonly audio: BrowserTabAudioState; +} + +/** Resolve browser-only session, desktop runtime, and audio state in one place. */ +export function resolveBrowserTabRuntimeState(input: { + surface: RightPanelSurface; + sessions: Readonly>; + desktopByTabId: Readonly>; + previewRuntimeTabId?: ((tabId: string) => string) | undefined; +}): BrowserTabRuntimeState { + const sessionTabId = + input.surface.kind === "preview" && input.surface.resourceId + ? (input.sessions[input.surface.resourceId]?.tabId ?? null) + : null; + const overlay = sessionTabId ? (input.desktopByTabId[sessionTabId] ?? null) : null; + const runtimeTabId = sessionTabId ? (input.previewRuntimeTabId?.(sessionTabId) ?? null) : null; + const audio = !overlay?.audible ? "none" : overlay.audioMuted ? "muted" : "audible"; + return { sessionTabId, runtimeTabId, overlay, audio }; +} + +/** + * Label and enabled state for a browser tab's mute menu entry. Overlay state + * must exist before the desktop tab can safely be addressed. + */ +export function browserTabMuteMenuItem(input: { + overlay: DesktopPreviewOverlay | null; + canResolveRuntimeTabId: boolean; +}): { label: string; disabled: boolean } { + const muted = input.overlay?.audioMuted ?? false; + return { + label: muted ? "Unmute tab" : "Mute tab", + disabled: input.overlay === null || !input.canResolveRuntimeTabId, + }; +} + +export function setBrowserTabAudioMuted(runtimeTabId: string, muted: boolean): void { + void previewBridge?.setAudioMuted(runtimeTabId, muted).catch(() => undefined); +} diff --git a/apps/web/src/components/rightPanelSurfaceActions.ts b/apps/web/src/components/rightPanelSurfaceActions.ts new file mode 100644 index 000000000000..7a984f890693 --- /dev/null +++ b/apps/web/src/components/rightPanelSurfaceActions.ts @@ -0,0 +1,276 @@ +import { PullRequestGlyph } from "./pullRequest/pullRequestIcons"; +import { + Bot, + FileDiff, + Files, + GitBranch, + Smartphone, + Globe2, + type LucideIcon, + TerminalSquare, +} from "lucide-react"; + +export type AddPanelSurfaceId = + | "source-control" + | "browser" + | "terminal" + | "files" + | "diff" + | "pull-request" + | "agents" + | "pull-requests" + | "device"; + +export type AddPanelSurfacePlacement = "empty-state" | "menu"; +export type AddPanelSurfaceInstancePolicy = "singleton" | "multiple"; + +export interface AddPanelSurfaceActionProps { + readonly onAddBrowser: () => void; + readonly onAddTerminal: () => void; + readonly onAddDiff: () => void; + readonly onAddFiles: () => void; + readonly onAddSourceControl: () => void; + readonly onAddPullRequest: () => void; + readonly onAddAgents: () => void; + readonly browserAvailable: boolean; + readonly terminalAvailable: boolean; + readonly diffAvailable: boolean; + readonly filesAvailable: boolean; + readonly sourceControlAvailable: boolean; + readonly pullRequestAvailable: boolean; + readonly agentsAvailable: boolean; + readonly onAddPullRequests: () => void; + readonly onAddDevice: () => void; + readonly pullRequestsAvailable: boolean; + readonly deviceAvailable: boolean; + readonly liveAgentCount: number; +} + +type AvailabilityKey = keyof Pick< + AddPanelSurfaceActionProps, + | "browserAvailable" + | "terminalAvailable" + | "diffAvailable" + | "filesAvailable" + | "sourceControlAvailable" + | "pullRequestAvailable" + | "agentsAvailable" + | "pullRequestsAvailable" + | "deviceAvailable" +>; + +type ActivationKey = keyof Pick< + AddPanelSurfaceActionProps, + | "onAddBrowser" + | "onAddTerminal" + | "onAddDiff" + | "onAddFiles" + | "onAddSourceControl" + | "onAddPullRequest" + | "onAddAgents" + | "onAddPullRequests" + | "onAddDevice" +>; + +interface AddPanelSurfaceDescriptor { + readonly id: AddPanelSurfaceId; + readonly label: string; + readonly description: string; + readonly icon: LucideIcon; + readonly shortcut: string; + readonly availability: AvailabilityKey; + readonly activation: ActivationKey; + readonly instancePolicy: AddPanelSurfaceInstancePolicy; + readonly order: Readonly>; + readonly disabledReason: string; + readonly unavailableHint: string; + readonly badge: "live-agents" | null; +} + +/** + * Canonical policy for every addable right-panel surface. Both launchers, + * their keyboard handlers, and focused tests consume actions built from this + * table so Source Control's singleton and placement rules cannot drift while + * multi-tab Pull Request and Browser behavior remains explicit. + */ +export const ADD_PANEL_SURFACE_DESCRIPTORS = [ + { + id: "source-control", + label: "Version Control", + description: "Review repository changes and sync state.", + icon: GitBranch, + shortcut: "V", + availability: "sourceControlAvailable", + activation: "onAddSourceControl", + instancePolicy: "singleton", + order: { "empty-state": 0, menu: 8 }, + disabledReason: "Version Control is only available when a project is open in a Git repository.", + unavailableHint: "Available for Git repositories.", + badge: null, + }, + { + id: "browser", + label: "Browser", + description: "Open a local app or URL.", + icon: Globe2, + shortcut: "B", + availability: "browserAvailable", + activation: "onAddBrowser", + instancePolicy: "multiple", + order: { "empty-state": 1, menu: 0 }, + disabledReason: "Browser previews are only available in the T3 Code desktop app.", + unavailableHint: "Only available in the desktop app.", + badge: null, + }, + { + id: "terminal", + label: "Terminal", + description: "Start a shell in this workspace.", + icon: TerminalSquare, + shortcut: "T", + availability: "terminalAvailable", + activation: "onAddTerminal", + instancePolicy: "multiple", + order: { "empty-state": 2, menu: 1 }, + disabledReason: "Terminal surfaces are only available from a project thread.", + unavailableHint: "Available when a project is open.", + badge: null, + }, + { + id: "files", + label: "Files", + description: "Browse and read workspace files.", + icon: Files, + shortcut: "F", + availability: "filesAvailable", + activation: "onAddFiles", + instancePolicy: "singleton", + order: { "empty-state": 3, menu: 2 }, + disabledReason: "Files are only available when a project is open.", + unavailableHint: "Available when a project is open.", + badge: null, + }, + { + id: "diff", + label: "Diff", + description: "Review changes in this thread.", + icon: FileDiff, + shortcut: "D", + availability: "diffAvailable", + activation: "onAddDiff", + instancePolicy: "singleton", + order: { "empty-state": 4, menu: 3 }, + disabledReason: "Diff is only available for server threads in Git repositories.", + unavailableHint: "Available for Git repositories.", + badge: null, + }, + { + id: "pull-request", + label: "Pull request", + description: "Open this branch's pull request.", + icon: PullRequestGlyph.pullRequest, + shortcut: "P", + availability: "pullRequestAvailable", + activation: "onAddPullRequest", + instancePolicy: "multiple", + order: { "empty-state": 5, menu: 4 }, + disabledReason: "This thread's branch has no pull request yet.", + unavailableHint: "No pull request on this branch yet.", + badge: null, + }, + { + id: "agents", + label: "Agents", + description: "Watch subagents and workflows run.", + icon: Bot, + shortcut: "A", + availability: "agentsAvailable", + activation: "onAddAgents", + instancePolicy: "singleton", + order: { "empty-state": 7, menu: 6 }, + disabledReason: "Agents are only available from a thread.", + unavailableHint: "Available from a thread.", + badge: "live-agents", + }, + { + id: "pull-requests", + label: "Linked pull requests", + description: "Open this thread's linked pull requests.", + icon: PullRequestGlyph.link, + shortcut: "L", + availability: "pullRequestsAvailable", + activation: "onAddPullRequests", + instancePolicy: "singleton", + order: { "empty-state": 6, menu: 5 }, + disabledReason: "No linked pull requests are available for this thread.", + unavailableHint: "No linked pull requests available.", + badge: null, + }, + { + id: "device", + label: "Device", + description: "Watch an iOS Simulator or Android Emulator.", + icon: Smartphone, + shortcut: "M", + availability: "deviceAvailable", + activation: "onAddDevice", + instancePolicy: "multiple", + order: { "empty-state": 8, menu: 7 }, + disabledReason: "Devices are only available from a thread.", + unavailableHint: "Available from a thread.", + badge: null, + }, +] as const satisfies readonly AddPanelSurfaceDescriptor[]; + +export interface AddPanelSurfaceAction { + readonly id: AddPanelSurfaceId; + readonly label: string; + readonly description: string; + readonly icon: LucideIcon; + readonly shortcut: string; + readonly instancePolicy: AddPanelSurfaceInstancePolicy; + readonly available: boolean; + readonly disabledReason: string; + readonly unavailableHint: string; + readonly onClick: () => void; + readonly badgeCount: number; +} + +export function buildAddSurfaceActions( + props: AddPanelSurfaceActionProps, + placement: AddPanelSurfacePlacement = "empty-state", +): readonly AddPanelSurfaceAction[] { + return ADD_PANEL_SURFACE_DESCRIPTORS.map((descriptor) => ({ + id: descriptor.id, + label: descriptor.label, + description: descriptor.description, + icon: descriptor.icon, + shortcut: descriptor.shortcut, + instancePolicy: descriptor.instancePolicy, + available: props[descriptor.availability], + disabledReason: descriptor.disabledReason, + unavailableHint: descriptor.unavailableHint, + onClick: props[descriptor.activation], + badgeCount: descriptor.badge === "live-agents" ? props.liveAgentCount : 0, + order: descriptor.order[placement], + })) + .sort((left, right) => left.order - right.order) + .map(({ order: _order, ...action }) => action); +} + +type SurfaceShortcutEvent = Pick< + KeyboardEvent, + "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" +>; + +export function surfaceShortcutActionForKey< + const Action extends { available: boolean; shortcut: string }, +>(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { + if (event.defaultPrevented || event.isComposing) return null; + if (event.metaKey || event.ctrlKey || event.altKey) return null; + return ( + actions.find( + (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(), + ) ?? null + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 3f9d233ed278..a11920930844 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -88,6 +88,7 @@ describe("background activity settings restore", () => { it("preserves advanced overrides when the shared policy changes", () => { const automaticGitFetchInterval = Duration.seconds(42); + const sourceControlAllRemotesFetchInterval = Duration.minutes(8); expect( backgroundActivitySharedPolicySettings( { @@ -98,6 +99,7 @@ describe("background activity settings restore", () => { baseProfile: "balanced", overrides: { automaticGitFetchInterval, + sourceControlAllRemotesFetchInterval, pauseWhenOnBattery: true, }, }, @@ -110,6 +112,7 @@ describe("background activity settings restore", () => { baseProfile: "performance", overrides: { automaticGitFetchInterval, + sourceControlAllRemotesFetchInterval, pauseWhenOnBattery: true, }, }); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index d5c6359c1c9f..268a108f5270 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -180,6 +180,7 @@ export function resolveBackgroundActivityProfileOption( baseProfile: resolved.profile, overrides: { automaticGitFetchInterval: resolved.automaticGitFetchInterval, + sourceControlAllRemotesFetchInterval: resolved.sourceControlAllRemotesFetchInterval, providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, @@ -319,6 +320,7 @@ export function backgroundActivityOverrideSettings( ) { const nextOverrides: BackgroundActivityOverridePatch = { automaticGitFetchInterval: resolved.automaticGitFetchInterval, + sourceControlAllRemotesFetchInterval: resolved.sourceControlAllRemotesFetchInterval, providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d44f64356b52..c399a76c0241 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -896,6 +896,9 @@ function BackgroundActivityAdvancedDialog({ const automaticGitFetchIntervalSeconds = durationToSeconds( resolvedBackgroundActivity.automaticGitFetchInterval, ); + const sourceControlAllRemotesFetchIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.sourceControlAllRemotesFetchInterval, + ); const providerHealthRefreshIntervalSeconds = durationToSeconds( resolvedBackgroundActivity.providerHealthRefreshInterval, ); @@ -1000,6 +1003,44 @@ function BackgroundActivityAdvancedDialog({
+
+
Version Control all remotes interval
+

+ Refresh every remote shown by an open Version Control panel. +

+
+
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + sourceControlAllRemotesFetchInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+
+ +
Provider health interval

diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 9fba0edc7d44..97980f5d7820 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -12,6 +12,7 @@ import type { VcsDriverKind, VcsDiscoveryItem, } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { getBackgroundActivityBaseProfile, getBackgroundActivityPresetSettings, @@ -83,6 +84,32 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial([ + "github", + "gitlab", + "azure-devops", + "bitbucket", +]); + +const SOURCE_CONTROL_AVATAR_PROVIDER_DESCRIPTIONS: Record = + { + github: "Fetch GitHub account avatars for commit rows from the configured GitHub remote.", + gitlab: + "Fetch GitLab account avatars for commit rows using GitLab's official Avatar API. GitLab may return external avatar-service URLs such as Gravatar or Libravatar.", + "azure-devops": + "Fetch Azure DevOps account avatars for commit rows from the configured Azure DevOps remote.", + bitbucket: + "Fetch Bitbucket account avatars for commit rows from the configured Bitbucket remote.", + }; + +function isSourceControlAvatarProviderKind( + provider: SourceControlProviderKind, +): provider is SourceControlAvatarProviderKind { + return SOURCE_CONTROL_AVATAR_PROVIDER_KINDS.has(provider as SourceControlAvatarProviderKind); +} + const VCS_ICONS: Partial> = { git: GitIcon, jj: JujutsuIcon, @@ -357,6 +384,9 @@ function GitFetchIntervalSettings() { const automaticGitFetchIntervalSeconds = durationToSeconds( resolvedBackgroundActivity.automaticGitFetchInterval, ); + const sourceControlAllRemotesFetchIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.sourceControlAllRemotesFetchInterval, + ); const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( getBackgroundActivityPresetSettings( getBackgroundActivityBaseProfile(settings.backgroundActivity), @@ -364,6 +394,14 @@ function GitFetchIntervalSettings() { ); const canResetFetchInterval = automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds; + const defaultSourceControlAllRemotesFetchIntervalSeconds = durationToSeconds( + getBackgroundActivityPresetSettings( + getBackgroundActivityBaseProfile(settings.backgroundActivity), + ).sourceControlAllRemotesFetchInterval, + ); + const canResetAllRemotesFetchInterval = + sourceControlAllRemotesFetchIntervalSeconds !== + defaultSourceControlAllRemotesFetchIntervalSeconds; const setting = searchableSetting("git-fetch-interval"); return ( @@ -399,7 +437,8 @@ function GitFetchIntervalSettings() {

- Refresh remote branches in the background. Set to 0 to avoid automatic Git prompts. + Refresh the current branch's upstream status in the background. Set to 0 to disable + automatic Git network requests.

@@ -426,10 +465,153 @@ function GitFetchIntervalSettings() { seconds
+
+
+
+
+ + {searchableSetting("all-remotes-fetch-interval").title} + + + This broader fetch is requested only while the Version Control panel is open and the + shared Background activity policy allows it. Custom intervals appear as Advanced in + General settings. + + + {canResetAllRemotesFetchInterval ? ( + + updateSettings( + backgroundActivityOverrideSettings(settings.backgroundActivity, { + sourceControlAllRemotesFetchInterval: undefined, + }), + ) + } + /> + ) : null} + +
+

+ Refresh every remote shown by the Version Control panel. Set this to 0 seconds to + fetch all remotes only through explicit Git actions. +

+
+
+ + updateSettings( + backgroundActivityOverrideSettings(settings.backgroundActivity, { + sourceControlAllRemotesFetchInterval: Duration.seconds( + normalizeFetchIntervalSeconds(value), + ), + }), + ) + } + > + + + + + + + seconds +
+
+
); } +function SourceControlProviderOptions({ + provider, +}: { + readonly provider: SourceControlAvatarProviderKind; +}) { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const providerSettings = settings.sourceControl.providers[provider]; + const defaultProviderSettings = DEFAULT_UNIFIED_SETTINGS.sourceControl.providers[provider]; + if ( + providerSettings === undefined || + defaultProviderSettings === undefined || + !SOURCE_CONTROL_AVATAR_PROVIDER_KINDS.has(provider) + ) { + return null; + } + + const showCommitAuthorAvatar = providerSettings.showCommitAuthorAvatar === true; + const canReset = showCommitAuthorAvatar !== defaultProviderSettings.showCommitAuthorAvatar; + const description = SOURCE_CONTROL_AVATAR_PROVIDER_DESCRIPTIONS[provider]; + + return ( +
+
+
+
+ + {searchableSetting("commit-author-avatars").title} + + + {canReset ? ( + + updateSettings({ + sourceControl: { + providers: { + ...settings.sourceControl.providers, + [provider]: { + showCommitAuthorAvatar: defaultProviderSettings.showCommitAuthorAvatar, + }, + }, + }, + }) + } + /> + ) : null} + +
+

{description}

+
+ + updateSettings({ + sourceControl: { + providers: { + ...settings.sourceControl.providers, + [provider]: { + showCommitAuthorAvatar: Boolean(checked), + }, + }, + }, + }) + } + aria-label="Show commit author avatars" + /> +
+
+ ); +} + function SourceControlSectionSkeleton({ title, headerAction, @@ -604,6 +786,9 @@ export function SourceControlSettingsPanel() { /> ) : undefined} + {isSourceControlAvatarProviderKind(item.kind) ? ( + + ) : undefined} ))} diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 9bd4f9906acd..81708f2f2e1c 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -312,6 +312,45 @@ describe("searchSettings", () => { ]); }); + it("indexes customized source control settings with stable destinations", () => { + expect(searchSettings("fetch interval")[0]).toMatchObject({ + id: "git-fetch-interval", + to: "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/settings/source-control", + targetId: "source-control", + }); + expect(searchSettings("all remotes interval")[0]).toMatchObject({ + id: "all-remotes-fetch-interval", + to: "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/settings/source-control", + targetId: "source-control", + }); + expect(searchSettings("commit author avatars")[0]).toMatchObject({ + id: "commit-author-avatars", + to: "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/settings/source-control", + targetId: "source-control", + }); + + const writingStyle = searchSettings("source control writing style")[0]; + expect(writingStyle).toMatchObject({ + id: "source-control-writing-style", + to: "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/settings/source-control", + }); + expect(writingStyle?.targetId).toBeUndefined(); + + const templates = searchSettings("change request templates")[0]; + expect(templates).toMatchObject({ + id: "follow-change-request-templates", + to: "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/settings/source-control", + }); + expect(templates?.targetId).toBeUndefined(); + + const writerModel = searchSettings("writer model")[0]; + expect(writerModel).toMatchObject({ + id: "source-control-writer-model", + to: "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/settings/source-control", + }); + expect(writerModel?.targetId).toBeUndefined(); + }); + it("routes browser recording quality to integrations", () => { const result = searchSettings("recording frame rate")[0]; expect(result).toMatchObject({ diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 8e3a3bad8438..85cfc8be5527 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -695,6 +695,9 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "git-fetch-interval", title: "Git fetch interval", to: "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/settings/source-control", + // Discovery-dependent controls live inside collapsible VCS rows. The + // section is the stable destination even when Git has not been detected. + targetId: "source-control", searchTerms: [ "automatic remote branch refresh background credentials security keys seconds off", ], @@ -709,6 +712,23 @@ export const SETTINGS_SEARCH_ITEMS = [ environmentOnly: true, scope: "environment-defaults", }, + { + id: "all-remotes-fetch-interval", + title: "Version Control all remotes interval", + to: "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/settings/source-control", + targetId: "source-control", + environmentOnly: true, + scope: "environment-defaults", + }, + { + id: "commit-author-avatars", + title: "Commit author avatars", + to: "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/settings/source-control", + // Provider rows are discovery-dependent and may be collapsed. + targetId: "source-control", + environmentOnly: true, + scope: "environment-defaults", + }, { id: "source-control-writing-style", title: "Source control writing style", diff --git a/apps/web/src/components/source-control/CommitFileChanges.tsx b/apps/web/src/components/source-control/CommitFileChanges.tsx new file mode 100644 index 000000000000..a61fb3396bcc --- /dev/null +++ b/apps/web/src/components/source-control/CommitFileChanges.tsx @@ -0,0 +1,59 @@ +import type { VcsPanelCommitSummary, VcsPanelFileChange } from "@t3tools/contracts"; +import { useEffect, useState, type ComponentProps } from "react"; +import { + isSourceControlPanelCommandInterrupted, + type useSourceControlPanelApi, +} from "~/state/sourceControlPanel"; +import { FileChangeList } from "./SourceControlPanelRows"; + +export function CommitFileChanges({ + commit, + api, + cwd, + ...props +}: Omit, "files"> & { + readonly commit: VcsPanelCommitSummary; + readonly api: ReturnType; + readonly cwd: string; +}) { + const [files, setFiles] = useState(null); + const [error, setError] = useState(null); + const [attempt, setAttempt] = useState(0); + useEffect(() => { + if (!commit.filesDeferred || !api) return; + let active = true; + void api.vcs + .commitFiles({ cwd, sha: commit.sha }, attempt > 0) + .then((result) => { + if (active) setFiles(result.files); + }) + .catch((cause: unknown) => { + if (active) + setError( + isSourceControlPanelCommandInterrupted(cause) + ? "Loading interrupted." + : "Could not load commit files.", + ); + }); + return () => { + active = false; + }; + }, [api, cwd, commit.sha, commit.filesDeferred, attempt]); + if (!commit.filesDeferred) return ; + if (error) + return ( + + ); + if (!files) + return
Loading files...
; + return ; +} diff --git a/apps/web/src/components/source-control/SourceControlActionableItem.tsx b/apps/web/src/components/source-control/SourceControlActionableItem.tsx new file mode 100644 index 000000000000..5227abac6e95 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlActionableItem.tsx @@ -0,0 +1,59 @@ +import { WorkingIndicator } from "../WorkingIndicator"; +import type { ReactNode } from "react"; + +export const actionableItemClassName = + "group/actionable-item relative w-full min-w-0 cursor-pointer rounded-md px-2.5 py-2 text-left text-sidebar-foreground hover:bg-sidebar-row-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; + +export function SourceControlActionableItem({ + kind, + icon, + title, + metadata, + trailingMetadata, + time, + activityLabel, + actions, +}: { + readonly kind: string; + readonly icon: ReactNode; + readonly title: ReactNode; + readonly metadata: ReactNode; + readonly trailingMetadata?: ReactNode; + readonly time?: ReactNode; + readonly activityLabel?: string | null | undefined; + readonly actions: ReactNode; +}) { + return ( + <> +
+ {icon} + {kind} + {activityLabel ? ( + + ) : ( +
+ + {time} + +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {actions} +
+
+ )} +
+
{title}
+
+
+ {metadata} +
+
+ {trailingMetadata} +
+
+ + ); +} diff --git a/apps/web/src/components/source-control/SourceControlEnvironmentPanel.tsx b/apps/web/src/components/source-control/SourceControlEnvironmentPanel.tsx new file mode 100644 index 000000000000..88f3ec92285a --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlEnvironmentPanel.tsx @@ -0,0 +1,44 @@ +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { useState } from "react"; + +import { PublishRepositoryDialog } from "../GitActionsControl"; +import { SourceControlPanelView } from "./SourceControlPanelView"; +import { useSourceControlPanelController } from "./useSourceControlPanelController"; +import type { SourceControlPeerSyncTarget } from "./SourceControlPanel.logic"; + +export interface SourceControlEnvironmentPanelProps { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly cwd: string; + readonly worktreePath: string | null; + readonly activeThreadRef: ScopedThreadRef | null; + readonly peerSyncTargets: readonly SourceControlPeerSyncTarget[]; + readonly repositoryAction?: ReactNode; + readonly onThreadRefChange?: (input: { + readonly branch: string | null; + readonly worktreePath: string | null; + }) => Promise | void; +} + +export function SourceControlEnvironmentPanel(props: SourceControlEnvironmentPanelProps) { + const [publishRepositoryCwd, setPublishRepositoryCwd] = useState(null); + + return ( + <> + + { + if (!open) setPublishRepositoryCwd(null); + }} + environmentId={props.environmentId} + threadRef={props.activeThreadRef} + gitCwd={publishRepositoryCwd ?? props.cwd} + /> + + ); +} diff --git a/apps/web/src/components/source-control/SourceControlPanel.logic.test.ts b/apps/web/src/components/source-control/SourceControlPanel.logic.test.ts new file mode 100644 index 000000000000..361b8e1a731c --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanel.logic.test.ts @@ -0,0 +1,946 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + type ThreadId, + type VcsPanelSnapshotResult, + type VcsRef, +} from "@t3tools/contracts"; + +import { + beginPanelFileDiffLoad, + beginPanelAction, + panelItemActivity, + beginPanelDetailRequest, + branchAttention, + branchHasUpstream, + branchNeedsRepositoryPublish, + branchIsCheckedOut, + branchOperationCwd, + branchSyncState, + completePanelFileDiffLoad, + confirmSourceControlPanelMutation, + drainPanelRefreshQueue, + failPanelFileDiffLoad, + formatRelativeDate, + isFederatedSourceControlTargetExpanded, + mergeChangeGroups, + namedBranchOperationCwd, + isLatestPanelDetailRequest, + pushBranchAndSyncPeers, + resolveFederatedSourceControlTargets, + resolveBranchSyncSnapshot, + runPanelActionAndReconcile, + stashIdentityKey, + vcsPanelSnapshotFingerprint, +} from "./SourceControlPanel.logic"; +import { + localBranchForRemoteBranch, + operationPathsForFile, + renameOriginalPathForFile, + sourceControlPanelError, +} from "./SourceControlPanelModel"; +import { sourceControlPanelStateCacheKey } from "./SourceControlPanelCache"; + +const PRIMARY_ENVIRONMENT_ID = EnvironmentId.make("environment-primary"); +const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); +const DISCONNECTED_ENVIRONMENT_ID = EnvironmentId.make("environment-disconnected"); + +const baseSnapshot: VcsPanelSnapshotResult = { + status: { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "split/vscode-extension-work", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 7, + behindCount: 47, + aheadOfDefaultCount: 0, + pr: null, + }, + changeGroups: [], + worktreeChangeSets: [], + localBranches: [], + branchDetails: [], + remotes: [ + { + name: "origin", + fetchUrl: "git@example.test:fork/repo.git", + pushUrl: "git@example.test:fork/repo.git", + provider: null, + branches: [], + }, + { + name: "upstream", + fetchUrl: "git@example.test:upstream/repo.git", + pushUrl: "git@example.test:upstream/repo.git", + provider: null, + branches: [{ name: "main", fullRefName: "upstream/main", isDefaultRemoteHead: true }], + }, + ], + actionableForkBranches: [], + stashes: [], + recentCommits: [], + defaultCompareRef: "upstream/main", +}; + +function branch(input: Partial): VcsRef { + return { + name: "split/vscode-extension-work", + current: false, + isDefault: false, + worktreePath: null, + ...input, + }; +} + +describe("SourceControlPanel branch sync logic", () => { + it("refreshes branch sync state after a fetch-before-sync request", async () => { + const calls: string[] = []; + const refreshedSnapshot = { + ...baseSnapshot, + status: { + ...baseSnapshot.status, + aheadCount: 0, + behindCount: 3, + }, + }; + + const resolved = await resolveBranchSyncSnapshot({ + snapshot: baseSnapshot, + fetchFirst: true, + fetch: async () => { + calls.push("fetch"); + }, + refreshSnapshot: async () => { + calls.push("snapshot"); + return refreshedSnapshot; + }, + }); + + expect(calls).toEqual(["fetch", "snapshot"]); + expect(resolved).toBe(refreshedSnapshot); + expect( + branchSyncState( + branch({ + name: "topic", + current: true, + upstreamName: "origin/topic", + upstreamRemoteName: "origin", + }), + resolved, + ), + ).toBe("pull"); + }); + + it("reuses the current branch sync snapshot when no prefetch is requested", async () => { + let fetched = false; + const resolved = await resolveBranchSyncSnapshot({ + snapshot: baseSnapshot, + fetchFirst: false, + fetch: async () => { + fetched = true; + }, + refreshSnapshot: async () => { + throw new Error("unexpected snapshot refresh"); + }, + }); + + expect(fetched).toBe(false); + expect(resolved).toBe(baseSnapshot); + }); + + it("publishes a local branch whose configured upstream is only its comparison base", () => { + const localBranch = branch({ + current: true, + upstreamName: "upstream/main", + upstreamRemoteName: "upstream", + aheadCount: 7, + behindCount: 47, + }); + + expect(branchHasUpstream(localBranch, baseSnapshot)).toBe(false); + expect(branchSyncState(localBranch, baseSnapshot)).toBe("publish"); + expect(branchAttention(localBranch, baseSnapshot)).toBe("unpushed"); + }); + + it("opens repository publishing instead of pushing when no remote exists", () => { + const unpublishedSnapshot = { + ...baseSnapshot, + status: { ...baseSnapshot.status, hasPrimaryRemote: false, hasUpstream: false }, + remotes: [], + }; + + expect(branchNeedsRepositoryPublish(branch({ current: true }), unpublishedSnapshot)).toBe(true); + expect(branchNeedsRepositoryPublish(branch({ current: true }), baseSnapshot)).toBe(false); + }); + + it("treats a same-name remote tracking branch as the sync upstream", () => { + const localBranch = branch({ + name: "split/subagent-threading-work", + upstreamName: "origin/split/subagent-threading-work", + upstreamRemoteName: "origin", + aheadCount: 0, + behindCount: 3, + }); + + expect(branchHasUpstream(localBranch, baseSnapshot)).toBe(true); + expect(branchSyncState(localBranch, baseSnapshot)).toBe("pull"); + expect(branchAttention(localBranch, baseSnapshot)).toBe("behind"); + }); + + it("targets the branch worktree cwd for branch operations when present", () => { + expect( + branchOperationCwd( + branch({ + worktreePath: "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/repo.worktrees/feature", + }), + "/repo", + ), + ).toBe("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/repo.worktrees/feature"); + expect(branchOperationCwd(branch({}), "/repo")).toBe("/repo"); + }); + + it("resolves named branch actions to the checkout that owns the branch", () => { + const branches = [ + branch({ name: "main", current: true, worktreePath: "/repo" }), + branch({ name: "feature", worktreePath: "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/repo.worktrees/feature" }), + ]; + + expect(namedBranchOperationCwd(branches, "feature", "/repo")).toBe("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/repo.worktrees/feature"); + expect(namedBranchOperationCwd(branches, "missing", "/repo")).toBe("/repo"); + }); + + it("treats current and sibling-worktree branches as checked out", () => { + expect(branchIsCheckedOut(branch({ current: true }))).toBe(true); + expect(branchIsCheckedOut(branch({ worktreePath: "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/repo.worktrees/feature" }))).toBe(true); + expect(branchIsCheckedOut(branch({}))).toBe(false); + expect(branchIsCheckedOut(undefined)).toBe(false); + }); +}); + +describe("SourceControlPanel working-tree presentation logic", () => { + it.each([".", undefined])( + "keeps %s upstream identity out of remote pairing and sync", + (upstreamRemoteName) => { + const local = branch({ + name: "main", + upstreamName: "origin/main", + ...(upstreamRemoteName ? { upstreamRemoteName } : {}), + }); + const tracker = branch({ + name: "tracker", + upstreamName: "origin/main", + upstreamRemoteName: "origin", + }); + const remote = { + ...baseSnapshot.remotes[0]!, + branches: [{ name: "main", fullRefName: "origin/main", isDefaultRemoteHead: false }], + }; + const snapshot = { ...baseSnapshot, localBranches: [local] }; + expect(localBranchForRemoteBranch(snapshot, remote, remote.branches[0]!)).toBeNull(); + expect(branchHasUpstream(local, snapshot)).toBe(false); + expect( + localBranchForRemoteBranch( + { ...snapshot, localBranches: [local, tracker] }, + remote, + remote.branches[0]!, + ), + ).toBe(tracker); + expect(branchHasUpstream({ ...local, upstreamRemoteName: "origin" }, snapshot)).toBe(true); + }, + ); + + it("sums staged and unstaged stats for the same path", () => { + expect( + mergeChangeGroups([ + { + kind: "staged", + files: [ + { + path: "src/file.ts", + originalPath: null, + status: "modified", + insertions: 2, + deletions: 1, + }, + ], + }, + { + kind: "unstaged", + files: [ + { + path: "src/file.ts", + originalPath: null, + status: "modified", + insertions: 3, + deletions: 4, + }, + ], + }, + ]), + ).toEqual([ + { + path: "src/file.ts", + originalPath: null, + status: "modified", + insertions: 5, + deletions: 5, + hasStagedChanges: true, + hasUnstagedChanges: true, + hasConflicts: false, + }, + ]); + }); + + it("preserves status precedence and conflict flags when merging paths", () => { + expect( + mergeChangeGroups([ + { + kind: "staged", + files: [ + { + path: "src/cafe.ts", + originalPath: null, + status: "modified", + insertions: 1, + deletions: 0, + }, + ], + }, + { + kind: "conflicts", + files: [ + { + path: "src/cafe.ts", + originalPath: null, + status: "conflicted", + insertions: 0, + deletions: 2, + }, + { + path: "src/áudio.ts", + originalPath: null, + status: "added", + insertions: 3, + deletions: 0, + }, + ], + }, + ]), + ).toEqual([ + { + path: "src/áudio.ts", + originalPath: null, + status: "added", + insertions: 3, + deletions: 0, + hasStagedChanges: false, + hasUnstagedChanges: false, + hasConflicts: true, + }, + { + path: "src/cafe.ts", + originalPath: null, + status: "conflicted", + insertions: 1, + deletions: 2, + hasStagedChanges: true, + hasUnstagedChanges: false, + hasConflicts: true, + }, + ]); + }); + + it("formats future timestamps as just now", () => { + const now = Date.parse("2026-06-20T12:00:00.000Z"); + const then = new Date(now + 5 * 60 * 1000).toISOString(); + + expect(formatRelativeDate(then, now)).toBe("just now"); + }); + + it("formats late-month dates before the one-year threshold as months", () => { + const now = Date.parse("2026-06-20T12:00:00.000Z"); + const then = new Date(now - 360 * 24 * 60 * 60 * 1000).toISOString(); + + expect(formatRelativeDate(then, now)).toBe("11 months ago"); + }); + + it("includes the original path for renames but not copies", () => { + expect( + operationPathsForFile({ + path: "src/renamed.ts", + originalPath: "src/original.ts", + status: "renamed", + }), + ).toEqual(["src/renamed.ts", "src/original.ts"]); + expect( + operationPathsForFile({ + path: "src/copied.ts", + originalPath: "src/original.ts", + status: "copied", + }), + ).toEqual(["src/copied.ts"]); + }); + + it("passes an original path to diff requests only for renames", () => { + expect( + renameOriginalPathForFile({ + originalPath: "src/original.ts", + status: "renamed", + }), + ).toBe("src/original.ts"); + expect( + renameOriginalPathForFile({ + originalPath: "src/original.ts", + status: "copied", + }), + ).toBeUndefined(); + }); +}); + +describe("SourceControlPanel stash identity", () => { + it("uses the stash commit hash instead of the positional ref when available", () => { + expect( + stashIdentityKey({ + refName: "stash@{0}", + sha: "abc123", + createdAt: "2026-06-30T13:00:00Z", + message: "WIP on main: abc123 change", + }), + ).toBe("sha:abc123"); + }); + + it("falls back to the positional ref for stashes without a hash", () => { + expect( + stashIdentityKey({ + refName: "stash@{0}", + sha: null, + createdAt: null, + message: "stash@{0}", + }), + ).toBe("ref:stash@{0}"); + }); +}); + +describe("SourceControlPanel mutation confirmation", () => { + it("uses the themed destructive confirmation contract", async () => { + const calls: Array<{ message: string; variant: string | undefined }> = []; + + const confirmed = await confirmSourceControlPanelMutation(async (message, options) => { + calls.push({ message, variant: options?.variant }); + return true; + }, "Discard 2 files?"); + + expect(confirmed).toBe(true); + expect(calls).toEqual([{ message: "Discard 2 files?", variant: "destructive" }]); + }); +}); + +describe("SourceControlPanel refresh stability logic", () => { + it("suppresses a duplicate panel action until the first action releases its key", () => { + const runningActionKeys = new Set(); + + expect(beginPanelAction(runningActionKeys, "commit:/repo")).toBe(true); + expect(beginPanelAction(runningActionKeys, "commit:/repo")).toBe(false); + expect(beginPanelAction(runningActionKeys, "push:feature")).toBe(true); + + runningActionKeys.delete("commit:/repo"); + expect(beginPanelAction(runningActionKeys, "commit:/repo")).toBe(true); + }); + + it("reconciles repository state after a failed panel action", async () => { + const calls: string[] = []; + const failure = new Error("merge produced conflicts"); + + const result = await runPanelActionAndReconcile({ + action: async () => { + calls.push("action"); + throw failure; + }, + reconcile: async () => { + calls.push("reconcile"); + }, + }); + + expect(calls).toEqual(["action", "reconcile"]); + expect(result).toEqual({ status: "failure", error: failure }); + }); + + it("preserves the mutation error when reconciliation also fails", async () => { + const mutationError = new Error("merge produced conflicts"); + const reconcileError = new Error("snapshot refresh failed"); + + const result = await runPanelActionAndReconcile({ + action: async () => { + throw mutationError; + }, + reconcile: async () => { + throw reconcileError; + }, + }); + expect(result).toEqual({ status: "failure", error: mutationError }); + }); + + for (const reconcileFails of [false, true]) { + it(`preserves synchronous action errors when reconciliation ${reconcileFails ? "fails" : "succeeds"}`, async () => { + const mutationError = new Error("action failed before returning a promise"); + let reconciled = false; + const result = await runPanelActionAndReconcile({ + action: () => { + throw mutationError; + }, + reconcile: async () => { + reconciled = true; + if (reconcileFails) throw new Error("snapshot refresh failed"); + }, + }); + expect(reconciled).toBe(true); + expect(result).toEqual({ status: "failure", error: mutationError }); + }); + } + + it("returns the refresh error after a successful mutation", async () => { + const reconcileError = new Error("snapshot refresh failed"); + const result = await runPanelActionAndReconcile({ + action: async () => {}, + reconcile: async () => { + throw reconcileError; + }, + }); + expect(result).toEqual({ status: "failure", error: reconcileError }); + }); + + it("returns success after the mutation and reconciliation complete", async () => { + const calls: string[] = []; + const result = await runPanelActionAndReconcile({ + action: async () => { + calls.push("action"); + }, + reconcile: async () => { + calls.push("reconcile"); + }, + }); + expect(calls).toEqual(["action", "reconcile"]); + expect(result).toEqual({ status: "success" }); + }); + + it("keeps a mutation error visible when a later refresh clears its own error", () => { + expect(sourceControlPanelError(null, "merge produced conflicts")).toBe( + "merge produced conflicts", + ); + expect(sourceControlPanelError("snapshot refresh failed", "merge produced conflicts")).toBe( + "merge produced conflicts", + ); + }); + + it("rejects late branch-detail responses for the same rendered surface", () => { + const requests = new Map(); + const first = beginPanelDetailRequest(requests, "branch:feature"); + const second = beginPanelDetailRequest(requests, "branch:feature"); + + expect(isLatestPanelDetailRequest(requests, "branch:feature", first)).toBe(false); + expect(isLatestPanelDetailRequest(requests, "branch:feature", second)).toBe(true); + }); + + it("drains a queued refresh after the active refresh fails", async () => { + let queuedMode: "full" | "working-tree" | null = null; + const calls: string[] = []; + const errors: unknown[] = []; + + await drainPanelRefreshQueue({ + initialMode: "full", + clearQueuedMode: () => { + queuedMode = null; + }, + readQueuedMode: () => queuedMode, + run: async (mode) => { + calls.push(mode); + if (calls.length !== 1) return; + queuedMode = "working-tree"; + throw new Error("interrupted"); + }, + onError: (error) => { + errors.push(error); + }, + }); + + expect(calls).toEqual(["full", "working-tree"]); + expect(errors).toEqual([expect.objectContaining({ message: "interrupted" })]); + }); + + it("fingerprints snapshots with their cwd so equal snapshots from different repos are distinct", () => { + expect(vcsPanelSnapshotFingerprint("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/repo/one", baseSnapshot)).toBe( + vcsPanelSnapshotFingerprint("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/repo/one", { ...baseSnapshot }), + ); + expect(vcsPanelSnapshotFingerprint("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/repo/one", baseSnapshot)).not.toBe( + vcsPanelSnapshotFingerprint("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/repo/two", baseSnapshot), + ); + }); + + it("keeps a loaded diff mounted while a refresh revalidates it", () => { + const loaded = { status: "loaded", patch: "same patch" } as const; + + expect(beginPanelFileDiffLoad(loaded, { preserveLoaded: true })).toBe(loaded); + expect(completePanelFileDiffLoad(loaded, "same patch")).toBe(loaded); + expect(failPanelFileDiffLoad(loaded, "failed", { preserveLoaded: true })).toBe(loaded); + }); + + it("updates preserved loaded diffs only when the refreshed patch changes", () => { + const loaded = { status: "loaded", patch: "old patch" } as const; + + expect(completePanelFileDiffLoad(loaded, "new patch")).toEqual({ + status: "loaded", + patch: "new patch", + }); + expect(beginPanelFileDiffLoad(loaded)).toEqual({ status: "loading" }); + expect(failPanelFileDiffLoad(loaded, "failed")).toEqual({ + status: "error", + message: "failed", + }); + }); +}); + +describe("SourceControlPanel environment federation", () => { + const syncedMainSnapshot = ( + input: { + readonly aheadCount?: number; + readonly behindCount?: number; + readonly dirty?: boolean; + readonly remoteUrl?: string; + readonly upstreamRemoteName?: string; + } = {}, + ): VcsPanelSnapshotResult => { + const currentBranch = branch({ + name: "main", + current: true, + isDefault: true, + upstreamName: "origin/main", + upstreamRemoteName: input.upstreamRemoteName ?? "origin", + aheadCount: input.aheadCount ?? 0, + behindCount: input.behindCount ?? 0, + }); + return { + ...baseSnapshot, + status: { + ...baseSnapshot.status, + refName: "main", + hasWorkingTreeChanges: input.dirty ?? false, + aheadCount: input.aheadCount ?? 0, + behindCount: input.behindCount ?? 0, + }, + localBranches: [currentBranch], + remotes: [ + { + name: "origin", + fetchUrl: input.remoteUrl ?? "https://example.test/team/repo.git", + pushUrl: input.remoteUrl ?? "https://example.test/team/repo.git", + provider: null, + branches: [], + }, + ], + }; + }; + + it.each(["source", "peer"])("does not coordinate a local upstream on the %s", async (side) => { + const calls: string[] = []; + const sourceSnapshot = syncedMainSnapshot({ + aheadCount: 1, + upstreamRemoteName: side === "source" ? "." : "origin", + }); + const peerSnapshot = syncedMainSnapshot({ + behindCount: 1, + upstreamRemoteName: side === "peer" ? "." : "origin", + }); + await pushBranchAndSyncPeers({ + sourceEnvironmentId: PRIMARY_ENVIRONMENT_ID, + sourceBranch: sourceSnapshot.localBranches[0]!, + sourceSnapshot, + force: false, + peerTargets: [{ environmentId: REMOTE_ENVIRONMENT_ID, cwd: "/peer" }], + push: async () => { + calls.push("push"); + }, + readPeerSnapshot: async () => { + calls.push("snapshot"); + return peerSnapshot; + }, + fetchPeerBranch: async () => { + calls.push("fetch"); + }, + pullPeerBranch: async () => { + calls.push("pull"); + }, + }); + expect(calls).toEqual(side === "source" ? ["push"] : ["snapshot", "push"]); + }); + + it.each([0, 3])( + "fast-forwards a clean peer already %i commits behind on the same remote branch", + async (behindCount) => { + const calls: string[] = []; + const sourceSnapshot = syncedMainSnapshot({ + aheadCount: 1, + remoteUrl: "git@example.test:team/repo.git", + }); + const peerSnapshots = [ + syncedMainSnapshot({ behindCount }), + syncedMainSnapshot({ behindCount: behindCount + 1 }), + ]; + + await pushBranchAndSyncPeers({ + sourceEnvironmentId: PRIMARY_ENVIRONMENT_ID, + sourceBranch: sourceSnapshot.localBranches[0]!, + sourceSnapshot, + force: false, + peerTargets: [ + { environmentId: PRIMARY_ENVIRONMENT_ID, cwd: "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/local/repo" }, + { environmentId: REMOTE_ENVIRONMENT_ID, cwd: "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/remote/repo" }, + ], + push: async () => { + calls.push("push"); + }, + readPeerSnapshot: async () => { + calls.push("snapshot"); + return peerSnapshots.shift()!; + }, + fetchPeerBranch: async (_target, branchName) => { + calls.push(`fetch:${branchName}`); + }, + pullPeerBranch: async (_target, branchName) => { + calls.push(`pull:${branchName}`); + }, + }); + + expect(calls).toEqual(["snapshot", "push", "fetch:main", "snapshot", "pull:main"]); + }, + ); + + it.each([{ dirty: true, behindCount: 3 }, { aheadCount: 1 }, { aheadCount: 1, behindCount: 3 }])( + "skips dirty or locally ahead peers before pushing: %j", + async (peerState) => { + const calls: string[] = []; + const sourceSnapshot = syncedMainSnapshot({ aheadCount: 1 }); + const peers = new Map([ + [REMOTE_ENVIRONMENT_ID, syncedMainSnapshot(peerState)], + [ + DISCONNECTED_ENVIRONMENT_ID, + syncedMainSnapshot({ remoteUrl: "https://example.test/another/repo.git" }), + ], + ]); + + await pushBranchAndSyncPeers({ + sourceEnvironmentId: PRIMARY_ENVIRONMENT_ID, + sourceBranch: sourceSnapshot.localBranches[0]!, + sourceSnapshot, + force: false, + peerTargets: [ + { environmentId: REMOTE_ENVIRONMENT_ID, cwd: "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/remote/repo" }, + { environmentId: DISCONNECTED_ENVIRONMENT_ID, cwd: "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/other/repo" }, + ], + push: async () => { + calls.push("push"); + }, + readPeerSnapshot: async (target) => peers.get(target.environmentId)!, + fetchPeerBranch: async () => { + calls.push("fetch"); + }, + pullPeerBranch: async () => { + calls.push("pull"); + }, + }); + + expect(calls).toEqual(["push"]); + }, + ); + + it.each([{ aheadCount: 1 }, { aheadCount: 1, behindCount: 4 }, { dirty: true, behindCount: 4 }])( + "rechecks an eligible behind peer after fetch and skips changed state: %j", + async (peerState) => { + const calls: string[] = []; + const sourceSnapshot = syncedMainSnapshot({ aheadCount: 1 }); + const peerSnapshots = [syncedMainSnapshot({ behindCount: 3 }), syncedMainSnapshot(peerState)]; + + await pushBranchAndSyncPeers({ + sourceEnvironmentId: PRIMARY_ENVIRONMENT_ID, + sourceBranch: sourceSnapshot.localBranches[0]!, + sourceSnapshot, + force: false, + peerTargets: [{ environmentId: REMOTE_ENVIRONMENT_ID, cwd: "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/remote/repo" }], + push: async () => { + calls.push("push"); + }, + readPeerSnapshot: async () => peerSnapshots.shift()!, + fetchPeerBranch: async () => { + calls.push("fetch"); + }, + pullPeerBranch: async () => { + calls.push("pull"); + }, + }); + + expect(calls).toEqual(["push", "fetch"]); + }, + ); + + it("changes panel identity with every repository cache scope", () => { + const base = { + environmentId: PRIMARY_ENVIRONMENT_ID, + threadId: "thread-one" as ThreadId, + cwd: "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/repo/one", + worktreePath: null, + }; + + expect(sourceControlPanelStateCacheKey(base)).not.toBe( + sourceControlPanelStateCacheKey({ ...base, threadId: "thread-two" as ThreadId }), + ); + expect(sourceControlPanelStateCacheKey(base)).not.toBe( + sourceControlPanelStateCacheKey({ ...base, cwd: "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/repo/two" }), + ); + expect(sourceControlPanelStateCacheKey(base)).not.toBe( + sourceControlPanelStateCacheKey({ ...base, worktreePath: "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/worktrees/feature" }), + ); + }); + + it("always expands the current environment and collapses other environments by default", () => { + expect( + isFederatedSourceControlTargetExpanded( + { active: true, environmentId: PRIMARY_ENVIRONMENT_ID }, + new Set(), + ), + ).toBe(true); + expect( + isFederatedSourceControlTargetExpanded( + { active: false, environmentId: REMOTE_ENVIRONMENT_ID }, + new Set(), + ), + ).toBe(false); + expect( + isFederatedSourceControlTargetExpanded( + { active: false, environmentId: REMOTE_ENVIRONMENT_ID }, + new Set([REMOTE_ENVIRONMENT_ID]), + ), + ).toBe(true); + }); + + it("omits disconnected environments and keeps the active checkout first", () => { + expect( + resolveFederatedSourceControlTargets({ + activeEnvironmentId: REMOTE_ENVIRONMENT_ID, + activeCwd: "/remote/repo.worktrees/active", + activeWorktreePath: "/remote/repo.worktrees/active", + candidates: [ + { + environmentId: PRIMARY_ENVIRONMENT_ID, + label: "This device", + isPrimary: true, + machine: "laptop", + cwd: "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/local/repo", + connected: true, + }, + { + environmentId: DISCONNECTED_ENVIRONMENT_ID, + label: "Offline server", + isPrimary: false, + machine: "mac-mini", + cwd: "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/offline/repo", + connected: false, + }, + { + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Build server", + isPrimary: false, + machine: "mac-mini", + cwd: "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/remote/repo", + connected: true, + }, + ], + }), + ).toEqual([ + { + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Build server", + isPrimary: false, + machine: "mac-mini", + cwd: "/remote/repo.worktrees/active", + connected: true, + active: true, + worktreePath: "/remote/repo.worktrees/active", + }, + { + environmentId: PRIMARY_ENVIRONMENT_ID, + label: "This device", + isPrimary: true, + machine: "laptop", + cwd: "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/local/repo", + connected: true, + active: false, + worktreePath: null, + }, + ]); + }); + + it("deduplicates multiple physical projects in the same connected environment", () => { + expect( + resolveFederatedSourceControlTargets({ + activeEnvironmentId: PRIMARY_ENVIRONMENT_ID, + activeCwd: "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/local/repo", + activeWorktreePath: null, + candidates: [ + { + environmentId: PRIMARY_ENVIRONMENT_ID, + label: "This device", + isPrimary: true, + machine: "laptop", + cwd: "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/local/repo", + connected: true, + }, + { + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Build server", + isPrimary: false, + machine: "mac-mini", + cwd: "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/remote/repo", + connected: true, + }, + { + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Build server", + isPrimary: false, + machine: "mac-mini", + cwd: "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/remote/repo/packages/web", + connected: true, + }, + ], + }).map((target) => target.cwd), + ).toEqual(["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/local/repo", "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/remote/repo"]); + }); +}); + +describe("panel item activity", () => { + it("keeps simultaneous items independent and includes actions on their children", () => { + const running = new Map([ + ["changes-commit", "Committing"], + ["worktree:/other:changes-stash", "Stashing"], + ["commit-undo:feature/topic:abc123", "Undoing"], + ]); + expect(panelItemActivity(running, ["changes-commit"])).toBe("Committing"); + expect(panelItemActivity(running, ["worktree:/other:changes-stash"])).toBe("Stashing"); + expect(panelItemActivity(running, [], ["commit-undo:feature/topic:"])).toBe("Undoing"); + expect(panelItemActivity(running, [], ["commit-undo:feature/top:"])).toBeNull(); + running.delete("changes-commit"); + expect(panelItemActivity(running, ["changes-commit"])).toBeNull(); + }); + + it("serializes stash mutations while only the affected stash has an activity label", () => { + const keys = new Set(); + expect(beginPanelAction(keys, "stash-mutation:sha-one")).toBe(true); + expect(beginPanelAction(keys, "stash-mutation:sha-two")).toBe(false); + const running = new Map([["stash-mutation:sha-one", "Applying"]]); + expect(panelItemActivity(running, ["stash-mutation:sha-one"])).toBe("Applying"); + expect(panelItemActivity(running, ["stash-mutation:sha-two"])).toBeNull(); + keys.delete("stash-mutation:sha-one"); + expect(beginPanelAction(keys, "stash-mutation:sha-two")).toBe(true); + }); +}); diff --git a/apps/web/src/components/source-control/SourceControlPanel.logic.ts b/apps/web/src/components/source-control/SourceControlPanel.logic.ts new file mode 100644 index 000000000000..dc538a2ae404 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanel.logic.ts @@ -0,0 +1,423 @@ +import type { + EnvironmentId, + EnvironmentMachineKind, + LocalApi, + ProjectId, + ProjectScript, + VcsPanelChangeGroup, + VcsPanelStash, + VcsPanelSnapshotResult, + VcsRef, +} from "@t3tools/contracts"; +import { + mergePanelChangeGroups, + panelBranchAttention as branchAttention, + panelBranchHasUpstream as branchHasUpstream, + panelBranchOperationCwd as branchOperationCwd, + panelBranchSyncCounts as branchSyncCounts, + panelBranchSyncState as branchSyncState, + type BranchAttentionKind, + type BranchSyncState, + type PanelChangedFile, +} from "@t3tools/shared/sourceControl"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; + +export { + branchAttention, + branchHasUpstream, + branchOperationCwd, + branchSyncCounts, + branchSyncState, +}; +export type { BranchSyncState, PanelChangedFile }; + +export type AttentionKind = BranchAttentionKind; + +export type PanelRefreshMode = "full" | "working-tree"; + +export function confirmSourceControlPanelMutation( + confirm: LocalApi["dialogs"]["confirm"], + message: string, +): Promise { + return confirm(message, { variant: "destructive" }); +} + +export interface SourceControlEnvironmentCandidate { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPrimary: boolean; + readonly machine: EnvironmentMachineKind; + readonly cwd: string; + readonly connected: boolean; + readonly project?: { + readonly id: ProjectId; + readonly workspaceRoot: string; + readonly scripts: readonly ProjectScript[]; + readonly preferredScriptId: string | null; + }; +} + +export interface FederatedSourceControlTarget extends SourceControlEnvironmentCandidate { + readonly active: boolean; + readonly worktreePath: string | null; +} + +export interface SourceControlPeerSyncTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; +} + +export function isFederatedSourceControlTargetExpanded( + target: Pick, + expandedEnvironmentIds: ReadonlySet, +): boolean { + return target.active || expandedEnvironmentIds.has(target.environmentId); +} + +export function branchNeedsRepositoryPublish( + branch: VcsRef, + snapshot: VcsPanelSnapshotResult, +): boolean { + return snapshot.remotes.length === 0 && !branchHasUpstream(branch, snapshot); +} + +export function resolveFederatedSourceControlTargets(input: { + readonly activeEnvironmentId: EnvironmentId; + readonly activeCwd: string; + readonly activeWorktreePath: string | null; + readonly candidates: readonly SourceControlEnvironmentCandidate[]; +}): FederatedSourceControlTarget[] { + const targetByEnvironmentId = new Map(); + for (const candidate of input.candidates) { + if (!candidate.connected || targetByEnvironmentId.has(candidate.environmentId)) continue; + const active = candidate.environmentId === input.activeEnvironmentId; + targetByEnvironmentId.set(candidate.environmentId, { + ...candidate, + active, + cwd: active ? input.activeCwd : candidate.cwd, + worktreePath: active ? input.activeWorktreePath : null, + }); + } + + return [...targetByEnvironmentId.values()].toSorted((left, right) => { + if (left.active !== right.active) return left.active ? -1 : 1; + if (left.isPrimary !== right.isPrimary) return left.isPrimary ? -1 : 1; + return left.label.localeCompare(right.label); + }); +} + +interface TrackedBranchRemote { + readonly branchName: string; + readonly remoteKey: string; +} + +function trackedBranchRemote( + branch: VcsRef, + snapshot: VcsPanelSnapshotResult, + direction: "fetch" | "push", +): TrackedBranchRemote | null { + const upstreamName = branch.upstreamName?.trim(); + if (!upstreamName || !branch.upstreamRemoteName || branch.upstreamRemoteName === ".") return null; + + const remote = snapshot.remotes.find((candidate) => candidate.name === branch.upstreamRemoteName); + if (!remote || !upstreamName.startsWith(`${remote.name}/`)) return null; + + const remoteUrl = + direction === "push" + ? (remote.pushUrl ?? remote.fetchUrl) + : (remote.fetchUrl ?? remote.pushUrl); + if (!remoteUrl) return null; + + return { + branchName: upstreamName.slice(remote.name.length + 1), + remoteKey: normalizeGitRemoteUrl(remoteUrl), + }; +} + +function eligiblePeerBranch(input: { + readonly snapshot: VcsPanelSnapshotResult; + readonly branchName: string; + readonly remote: TrackedBranchRemote; +}): VcsRef | null { + if ( + !input.snapshot.status.isRepo || + input.snapshot.status.refName !== input.branchName || + input.snapshot.status.hasWorkingTreeChanges + ) { + return null; + } + + const branch = input.snapshot.localBranches.find( + (candidate) => candidate.current && candidate.name === input.branchName, + ); + if (!branch || !branchHasUpstream(branch, input.snapshot)) return null; + + const trackedRemote = trackedBranchRemote(branch, input.snapshot, "fetch"); + if ( + !trackedRemote || + trackedRemote.remoteKey !== input.remote.remoteKey || + trackedRemote.branchName !== input.remote.branchName + ) { + return null; + } + + const counts = branchSyncCounts(branch, input.snapshot); + if (counts.aheadCount > 0) return null; + return branch; +} + +/** + * Push one branch and then fast-forward matching checkouts on other connected environments. + * Peer reads and mutations are best-effort because their availability must not turn a successful + * source push into a reported push failure. + */ +export async function pushBranchAndSyncPeers(options: { + readonly sourceEnvironmentId: EnvironmentId; + readonly sourceBranch: VcsRef; + readonly sourceSnapshot: VcsPanelSnapshotResult; + readonly force: boolean; + readonly peerTargets: readonly SourceControlPeerSyncTarget[]; + readonly push: () => Promise; + readonly readPeerSnapshot: ( + target: SourceControlPeerSyncTarget, + ) => Promise; + readonly fetchPeerBranch: ( + target: SourceControlPeerSyncTarget, + branchName: string, + ) => Promise; + readonly pullPeerBranch: ( + target: SourceControlPeerSyncTarget, + branchName: string, + ) => Promise; + readonly onPeerError?: (target: SourceControlPeerSyncTarget, error: unknown) => void; +}): Promise { + const sourceRemote = trackedBranchRemote(options.sourceBranch, options.sourceSnapshot, "push"); + const shouldSyncPeers = + !options.force && + sourceRemote !== null && + branchSyncState(options.sourceBranch, options.sourceSnapshot) === "push"; + + if (!shouldSyncPeers) { + await options.push(); + return; + } + + const peerTargets = options.peerTargets.filter( + (target) => target.environmentId !== options.sourceEnvironmentId, + ); + const preparedPeers = ( + await Promise.all( + peerTargets.map(async (target) => { + try { + const snapshot = await options.readPeerSnapshot(target); + return eligiblePeerBranch({ + snapshot, + branchName: options.sourceBranch.name, + remote: sourceRemote, + }) + ? target + : null; + } catch (error) { + options.onPeerError?.(target, error); + return null; + } + }), + ) + ).filter((target): target is SourceControlPeerSyncTarget => target !== null); + + await options.push(); + + await Promise.all( + preparedPeers.map(async (target) => { + try { + await options.fetchPeerBranch(target, options.sourceBranch.name); + const snapshot = await options.readPeerSnapshot(target); + const branch = eligiblePeerBranch({ + snapshot, + branchName: options.sourceBranch.name, + remote: sourceRemote, + }); + if (!branch) return; + if (branchSyncCounts(branch, snapshot).behindCount > 0) { + await options.pullPeerBranch(target, options.sourceBranch.name); + } + } catch (error) { + options.onPeerError?.(target, error); + } + }), + ); +} + +export async function drainPanelRefreshQueue(options: { + readonly initialMode: PanelRefreshMode; + readonly clearQueuedMode: () => void; + readonly readQueuedMode: () => PanelRefreshMode | null; + readonly run: (mode: PanelRefreshMode) => Promise; + readonly onError: (error: unknown) => void; +}): Promise { + let mode = options.initialMode; + while (true) { + options.clearQueuedMode(); + try { + await options.run(mode); + } catch (error) { + options.onError(error); + } + const queuedMode = options.readQueuedMode(); + if (queuedMode === null) return; + mode = queuedMode; + } +} + +export type PanelActionResult = + | { readonly status: "success" } + | { readonly status: "failure"; readonly error: unknown }; + +export async function runPanelActionAndReconcile(options: { + readonly action: () => Promise; + readonly reconcile: () => Promise; +}): Promise { + let result: PanelActionResult; + try { + await options.action(); + result = { status: "success" }; + } catch (error) { + result = { status: "failure", error }; + } + try { + await options.reconcile(); + } catch (error) { + return result.status === "failure" ? result : { status: "failure", error }; + } + return result; +} + +export async function resolveBranchSyncSnapshot(options: { + readonly snapshot: VcsPanelSnapshotResult; + readonly fetchFirst: boolean; + readonly fetch: () => Promise; + readonly refreshSnapshot: () => Promise; +}): Promise { + if (!options.fetchFirst) return options.snapshot; + await options.fetch(); + return options.refreshSnapshot(); +} + +export function beginPanelAction(runningActionKeys: Set, actionKey: string): boolean { + if (runningActionKeys.has(actionKey)) return false; + // Stash mutations can renumber every stash ref, so only one can run at a time. + if ( + actionKey.startsWith("stash-mutation:") && + [...runningActionKeys].some((key) => key.startsWith("stash-mutation:")) + ) + return false; + runningActionKeys.add(actionKey); + return true; +} + +export function beginPanelDetailRequest(requestsByKey: Map, key: string): number { + const requestId = (requestsByKey.get(key) ?? 0) + 1; + requestsByKey.set(key, requestId); + return requestId; +} + +export function isLatestPanelDetailRequest( + requestsByKey: ReadonlyMap, + key: string, + requestId: number, +): boolean { + return requestsByKey.get(key) === requestId; +} + +export function branchIsCheckedOut(branch: VcsRef | undefined): boolean { + return branch?.current === true || branch?.worktreePath != null; +} + +export function namedBranchOperationCwd( + branches: readonly VcsRef[], + branchName: string, + fallbackCwd: string, +): string { + const branch = branches.find((candidate) => candidate.name === branchName); + return branch ? branchOperationCwd(branch, fallbackCwd) : fallbackCwd; +} + +export type PanelFileDiffLoadState = + | { readonly status: "loading" } + | { readonly status: "loaded"; readonly patch: string } + | { readonly status: "error"; readonly message: string }; + +export function vcsPanelSnapshotFingerprint(cwd: string, snapshot: VcsPanelSnapshotResult): string { + return `${cwd}\0${JSON.stringify(snapshot)}`; +} + +export function stashIdentityKey(stash: VcsPanelStash): string { + return stash.sha ? `sha:${stash.sha}` : `ref:${stash.refName}`; +} + +export function beginPanelFileDiffLoad( + current: PanelFileDiffLoadState | undefined, + options: { readonly preserveLoaded?: boolean } = {}, +): PanelFileDiffLoadState { + if (options.preserveLoaded && current?.status === "loaded") return current; + return { status: "loading" }; +} + +export function completePanelFileDiffLoad( + current: PanelFileDiffLoadState | undefined, + patch: string, +): PanelFileDiffLoadState { + if (current?.status === "loaded" && current.patch === patch) return current; + return { status: "loaded", patch }; +} + +export function failPanelFileDiffLoad( + current: PanelFileDiffLoadState | undefined, + message: string, + options: { readonly preserveLoaded?: boolean } = {}, +): PanelFileDiffLoadState { + if (options.preserveLoaded && current?.status === "loaded") return current; + return { status: "error", message }; +} + +export function mergeChangeGroups(groups: readonly VcsPanelChangeGroup[]): PanelChangedFile[] { + return mergePanelChangeGroups(groups); +} + +export function formatRelativeDate( + value: string | null | undefined, + now = Date.now(), +): string | null { + if (!value) return null; + const time = Date.parse(value); + if (!Number.isFinite(time)) return null; + const elapsedMs = now - time; + if (elapsedMs <= 0) return "just now"; + if (elapsedMs < 60_000) return "just now"; + const minutes = Math.floor(elapsedMs / 60_000); + if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.floor(hours / 24); + if (days === 1) return "yesterday"; + if (days < 7) return `${days} days ago`; + const weeks = Math.floor(days / 7); + if (weeks === 1) return "last week"; + if (days < 30) return `${weeks} weeks ago`; + const months = Math.min(11, Math.floor(days / 30)); + if (days < 365) return `${months} month${months === 1 ? "" : "s"} ago`; + const years = Math.floor(days / 365); + return `${years} year${years === 1 ? "" : "s"} ago`; +} + +/** Match the item being acted on, including operations on its expanded children. */ +export function panelItemActivity( + runningActions: ReadonlyMap, + keys: readonly string[], + prefixes: readonly string[] = [], +): string | null { + for (const [key, label] of runningActions) { + if (keys.includes(key) || prefixes.some((prefix) => key.startsWith(prefix))) return label; + } + return null; +} diff --git a/apps/web/src/components/source-control/SourceControlPanel.tsx b/apps/web/src/components/source-control/SourceControlPanel.tsx new file mode 100644 index 000000000000..86a5ec3afaf2 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanel.tsx @@ -0,0 +1,286 @@ +import type { + EnvironmentId, + ProjectScript, + ResolvedKeybindingsConfig, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; +import { useMemo, useState } from "react"; + +import { cn } from "~/lib/utils"; +import { EnvironmentMachineIcon } from "~/components/EnvironmentMachineIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; +import ProjectScriptsControl, { + type NewProjectScriptInput, + type ProjectScriptActionResult, +} from "~/components/ProjectScriptsControl"; + +import { + isFederatedSourceControlTargetExpanded, + resolveFederatedSourceControlTargets, + type FederatedSourceControlTarget, + type SourceControlEnvironmentCandidate, + type SourceControlPeerSyncTarget, +} from "./SourceControlPanel.logic"; +import { + SourceControlEnvironmentPanel, + type SourceControlEnvironmentPanelProps, +} from "./SourceControlEnvironmentPanel"; +import { sourceControlPanelStateCacheKey } from "./SourceControlPanelCache"; + +interface SourceControlPanelProps { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly cwd: string; + readonly worktreePath: string | null; + readonly environments: readonly SourceControlEnvironmentCandidate[]; + readonly keybindings: ResolvedKeybindingsConfig; + readonly onRunProjectScript: ( + target: SourceControlProjectActionTarget, + script: ProjectScript, + ) => void; + readonly onAddProjectScript: ( + target: SourceControlProjectActionTarget, + input: NewProjectScriptInput, + ) => Promise; + readonly onUpdateProjectScript: ( + target: SourceControlProjectActionTarget, + scriptId: string, + input: NewProjectScriptInput, + ) => Promise; + readonly onDeleteProjectScript: ( + target: SourceControlProjectActionTarget, + scriptId: string, + ) => Promise; + readonly onThreadRefChange?: SourceControlEnvironmentPanelProps["onThreadRefChange"]; +} + +export interface SourceControlProjectActionTarget { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly projectId: NonNullable["id"]; + readonly cwd: string; + readonly projectCwd: string; + readonly worktreePath: string | null; + readonly scripts: readonly ProjectScript[]; +} + +function EnvironmentProjectActions({ + keybindings, + onAddProjectScript, + onDeleteProjectScript, + onRunProjectScript, + onUpdateProjectScript, + target, +}: { + readonly keybindings: ResolvedKeybindingsConfig; + readonly onRunProjectScript: SourceControlPanelProps["onRunProjectScript"]; + readonly onAddProjectScript: SourceControlPanelProps["onAddProjectScript"]; + readonly onUpdateProjectScript: SourceControlPanelProps["onUpdateProjectScript"]; + readonly onDeleteProjectScript: SourceControlPanelProps["onDeleteProjectScript"]; + readonly target: FederatedSourceControlTarget & { + readonly project: NonNullable; + }; +}) { + const fileScripts = useT3ProjectFileScripts(target.environmentId, target.project.workspaceRoot); + const actionTarget = useMemo( + () => ({ + environmentId: target.environmentId, + environmentLabel: target.label, + projectId: target.project.id, + cwd: target.cwd, + projectCwd: target.project.workspaceRoot, + worktreePath: target.worktreePath, + scripts: target.project.scripts, + }), + [ + target.cwd, + target.environmentId, + target.label, + target.project.id, + target.project.scripts, + target.project.workspaceRoot, + target.worktreePath, + ], + ); + + return ( + onRunProjectScript(actionTarget, script)} + onAddScript={(input) => onAddProjectScript(actionTarget, input)} + onUpdateScript={(scriptId, input) => onUpdateProjectScript(actionTarget, scriptId, input)} + onDeleteScript={(scriptId) => onDeleteProjectScript(actionTarget, scriptId)} + /> + ); +} + +export function SourceControlPanel({ + cwd, + environmentId, + environments, + keybindings, + onAddProjectScript, + onDeleteProjectScript, + onRunProjectScript, + onUpdateProjectScript, + onThreadRefChange, + threadId, + worktreePath, +}: SourceControlPanelProps) { + const [expandedEnvironmentIds, setExpandedEnvironmentIds] = useState>( + () => new Set(), + ); + const targets = useMemo( + () => + resolveFederatedSourceControlTargets({ + activeEnvironmentId: environmentId, + activeCwd: cwd, + activeWorktreePath: worktreePath, + candidates: environments, + }), + [cwd, environmentId, environments, worktreePath], + ); + const activeThreadRef = useMemo( + () => ({ environmentId, threadId }), + [environmentId, threadId], + ); + const peerSyncTargets = useMemo( + () => targets.map((target) => ({ environmentId: target.environmentId, cwd: target.cwd })), + [targets], + ); + + if (targets.length === 0) return null; + + const showEnvironmentHeaders = targets.length > 1 || targets.some((target) => !target.isPrimary); + if (!showEnvironmentHeaders) { + const target = targets[0]!; + const panelKey = sourceControlPanelStateCacheKey({ + environmentId: target.environmentId, + threadId, + cwd: target.cwd, + worktreePath: target.worktreePath, + }); + return ( + + ); + } + + return ( +
+ {targets.map((target) => { + const expanded = isFederatedSourceControlTargetExpanded(target, expandedEnvironmentIds); + const panelKey = sourceControlPanelStateCacheKey({ + environmentId: target.environmentId, + threadId, + cwd: target.cwd, + worktreePath: target.worktreePath, + }); + const environmentHeaderContents = ( +
+
+ + + {target.label} + +
+ + + } + > + {target.cwd} + + + {target.cwd} + + +
+ ); + return ( +
+ {target.active ? ( +
+ {environmentHeaderContents} +
+ ) : ( + + )} + {expanded ? ( + 1 && target.project ? ( + + ) : undefined + } + {...(target.active && onThreadRefChange ? { onThreadRefChange } : {})} + /> + ) : null} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/source-control/SourceControlPanelBranches.tsx b/apps/web/src/components/source-control/SourceControlPanelBranches.tsx new file mode 100644 index 000000000000..6d7ba226e8a3 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelBranches.tsx @@ -0,0 +1,957 @@ +import { panelItemActivity } from "./SourceControlPanel.logic"; +import { + actionableItemClassName, + SourceControlActionableItem, +} from "./SourceControlActionableItem"; +import { SourceControlVirtualList } from "./SourceControlVirtualList"; +import { CommitFileChanges } from "./CommitFileChanges"; +import type { + EnvironmentId, + ThreadId, + VcsPanelBranchDetails, + VcsPanelCommitSummary, + VcsPanelFileChange, + VcsPanelSnapshotResult, + VcsPanelStashDetails, + VcsPanelWorkingTreeFileEnrichmentResult, + VcsRef, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + ChevronDown, + ChevronRight, + GitBranch, + GitBranchPlus, + GitCommit, + GitMerge, + GitPullRequestArrow, + RefreshCw, + RotateCcw, + Trash2, + Undo2, +} from "lucide-react"; +import type { + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + ReactNode, +} from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { getRenderablePatch, resolveDiffThemeName } from "~/lib/diffRendering"; +import { cn } from "~/lib/utils"; +import { useEnvironmentQuery } from "~/state/query"; +import { + isSourceControlPanelCommandInterrupted, + resolveSourceControlPanelPresentationState, + useSourceControlPanelApi, +} from "~/state/sourceControlPanel"; +import { resolvePathLinkTarget } from "~/terminal-links"; + +import { Badge } from "../ui/badge"; +import { Checkbox } from "../ui/checkbox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Textarea } from "../ui/textarea"; +import { Tooltip, TooltipTrigger } from "../ui/tooltip"; +import { + type AttentionKind, + type BranchSyncState, + branchAttention, + branchHasUpstream, + branchIsCheckedOut, + branchSyncCounts, + branchSyncState, + drainPanelRefreshQueue, + namedBranchOperationCwd, + type PanelChangedFile, + stashIdentityKey, +} from "./SourceControlPanel.logic"; +import { + COMMIT_PAGE_SIZE, + MIN_SECTION_WEIGHT, + SECTION_TITLES, + applyWorkingTreeFileEnrichment, + commitUndoActionKey, + enrichmentFileKey, + expandedBranchesForSnapshot, + fileBasename, + isActionForced, + localOnlyBranches, + operationPathsForFile, + shouldEnrichWorkingTreeFile, + stashBranchName, + sumFiles, + treeKey, + worktreeChangeSetId, + commitCountLabel, + type FileDiffSource, + type WorkingTreeChangeSetView, +} from "./SourceControlPanelModel"; +import { + AttentionIcon, + BranchSyncActionIcon, + BranchSyncLabels, + BranchTooltip, + CommitTooltip, + CompactBadge, + IconButton, + RefLabels, + RowActions, + StatLabels, + WorkingTreeTooltip, + branchSyncActionLabel, +} from "./SourceControlPanelPrimitives"; +import type { ReadySourceControlPanelController } from "./useSourceControlPanelController"; +import { Download, Upload } from "lucide-react"; +import { TooltipCardPopup } from "../ui/tooltip"; +import { formatRelativeDate } from "./SourceControlPanel.logic"; +import { AuthorAvatar, SyncedIcon } from "./SourceControlPanelPrimitives"; +import { FileChangeList, FileChangeSummary, LoadMoreCommitsButton } from "./SourceControlPanelRows"; + +export function makeSourceControlPanelBranchRenderers( + controller: ReadySourceControlPanelController, +) { + const { + api, + branchDetailsByRef, + checkoutCommitDetached, + copyText, + createBranchFromCommit, + cwd, + deleteBranch, + expandedTree, + fileDiffKey, + fileDiffListProps, + isActionRunning, + isTreeExpanded, + loadMoreBranchCommits, + loadingBranchDetails, + mergeBranchIntoCurrent, + openContextMenu, + openFileChangeContextMenu, + openInVsCode, + rebaseCurrentOnto, + resolvedTheme, + revertCommit, + runAction, + runBranchSync, + setCompareBaseDialogTarget, + setCompareBaseQuery, + snapshot, + switchRef, + syncBranch, + toggleBranchTree, + toggleBranchTreeFromKeyboard, + toggleTree, + toggleTreeFromKeyboard, + undoCommit, + } = controller; + const renderCommit = ( + commit: VcsPanelCommitSummary, + options: { readonly undoBranchName?: string } = {}, + ) => { + const key = treeKey("commit", commit.sha); + const expanded = expandedTree.has(key); + const stats = commit.fileStats ?? sumFiles(commit.files); + const relativeDate = formatRelativeDate(commit.authoredAt); + const undoKey = options.undoBranchName + ? commitUndoActionKey(options.undoBranchName, commit.sha) + : null; + const revertKey = `commit-revert:${commit.sha}`; + const rebaseKey = `rebase-current:${commit.sha}`; + const checkoutKey = `commit-checkout:${commit.sha}`; + const createBranchKey = `commit-create-branch:${commit.sha}`; + const canUndoCommit = options.undoBranchName !== undefined; + return ( +
+ + toggleTree(key)} + onKeyDown={(event) => toggleTreeFromKeyboard(key, event)} + onContextMenu={(event) => + openContextMenu( + event, + [ + ...(canUndoCommit + ? [ + { + id: "undo", + label: "Undo", + disabled: undoKey ? isActionRunning(undoKey) : false, + }, + ] + : []), + { id: "revert", label: "Revert commit" }, + { id: "rebase", label: "Rebase current branch onto commit" }, + { id: "checkout", label: "Checkout as detached HEAD" }, + { id: "create-branch", label: "Create branch from commit" }, + { + id: "copy-sha", + label: "Copy SHA", + icon: "copy", + separatorBefore: true, + }, + { id: "copy-message", label: "Copy message", icon: "copy" }, + ], + { + undo: () => { + if (options.undoBranchName) undoCommit(options.undoBranchName, commit); + }, + revert: () => revertCommit(commit), + rebase: () => rebaseCurrentOnto(commit.sha), + checkout: () => checkoutCommitDetached(commit), + "create-branch": () => createBranchFromCommit(commit), + "copy-sha": () => copyText(commit.sha), + "copy-message": () => copyText(commit.message), + }, + ) + } + > + {expanded ? ( + + ) : ( + + )} + + {commit.message} + remote.name)} + /> + {commit.fileStats || !commit.filesDeferred ? ( + + ) : null} + {relativeDate ? ( + {relativeDate} + ) : null} + + {canUndoCommit && options.undoBranchName ? ( + { + if (options.undoBranchName) undoCommit(options.undoBranchName, commit); + }} + > + + + ) : null} + revertCommit(commit)} + > + + + rebaseCurrentOnto(commit.sha)} + > + + + checkoutCommitDetached(commit)} + > + + + createBranchFromCommit(commit)} + > + + + +
+ } + /> + + remote.name)} + /> + + + {expanded ? ( +
+ ({ kind: "commit", sha: commit.sha }))} + /> +
+ ) : null} + + ); + }; + + const renderBranchSubsection = ({ + details, + id, + title, + count, + children, + icon, + action, + defaultExpanded, + }: { + readonly details: VcsPanelBranchDetails; + readonly id: string; + readonly title: ReactNode; + readonly count: ReactNode | null; + readonly children: ReactNode; + readonly icon?: ReactNode; + readonly action?: ReactNode; + readonly defaultExpanded?: boolean; + }) => { + const key = treeKey("branch-subsection", `${details.fullRefName}:${id}`); + const expanded = isTreeExpanded(key, defaultExpanded); + return ( +
+
toggleTree(key, defaultExpanded)} + onKeyDown={(event) => toggleTreeFromKeyboard(key, event, defaultExpanded)} + > + {expanded ? ( + + ) : ( + + )} + {icon} + {title} + {action} + {count !== null ? {count} : null} +
+ {expanded ?
{children}
: null} +
+ ); + }; + + const renderBranchTree = (branch: VcsRef, details: VcsPanelBranchDetails, detailsKey: string) => { + const unsyncedCommitShas = new Set(details.unsyncedCommitShas); + const loadingDetails = loadingBranchDetails.has(detailsKey); + const aheadTotal = details.aheadCommits.length + details.aheadCommitsRemaining; + const behindTotal = details.behindCommits.length + details.behindCommitsRemaining; + const historyTotal = details.commits.length + details.commitsRemaining; + const openCompareBaseDialog = () => { + setCompareBaseDialogTarget({ branch, detailsKey }); + setCompareBaseQuery(""); + }; + const renderBranchCommit = (commit: VcsPanelCommitSummary) => + renderCommit( + commit, + unsyncedCommitShas.has(commit.sha) ? { undoBranchName: branch.name } : undefined, + ); + return ( +
+ + {aheadTotal > 0 + ? renderBranchSubsection({ + details, + id: "ahead", + title: `${aheadTotal} Ahead`, + count: null, + icon: , + children: ( +
+ commit.sha} + renderItem={renderBranchCommit} + /> + void loadMoreBranchCommits(branch, details, "ahead", detailsKey)} + /> +
+ ), + }) + : null} + {behindTotal > 0 + ? renderBranchSubsection({ + details, + id: "behind", + title: `${behindTotal} Behind`, + count: null, + icon: , + children: ( +
+ commit.sha} + renderItem={renderBranchCommit} + /> + + void loadMoreBranchCommits(branch, details, "behind", detailsKey) + } + /> +
+ ), + }) + : null} + {renderBranchSubsection({ + details, + id: "commits", + title: "History", + count: commitCountLabel(historyTotal), + children: ( +
+ {details.commits.length === 0 ? ( +
No commits.
+ ) : ( + commit.sha} + renderItem={renderBranchCommit} + /> + )} + void loadMoreBranchCommits(branch, details, "history", detailsKey)} + /> +
+ ), + })} + {details.baseRef + ? renderBranchSubsection({ + details, + id: "compare-changes", + title: "Changes", + count: null, + action: , + children: ( + ({ + kind: "compare", + baseRef: details.baseRef!, + refName: details.name, + }))} + /> + ), + }) + : null} +
+ ); + }; + + const branchRow = ( + branch: VcsRef, + options: { + readonly key?: string; + readonly detailsKey?: string; + readonly compareBaseRef?: string; + readonly syncCounts?: { readonly aheadCount: number; readonly behindCount: number }; + readonly attention?: AttentionKind; + readonly syncLabel?: string; + readonly syncState?: BranchSyncState; + readonly syncActionKey?: string; + readonly fetchActionKey?: string; + readonly onSync?: (event?: ReactMouseEvent) => void; + readonly secondaryBadge?: ReactNode; + readonly pullRequestBadge?: ReactNode; + } = {}, + ) => { + const detailsKey = options.detailsKey ?? branch.name; + const details = branchDetailsByRef.get(detailsKey); + const key = options.key ?? treeKey("branch", branch.name); + const expanded = expandedTree.has(key); + const loadingDetails = loadingBranchDetails.has(detailsKey); + const current = branch.current; + const { aheadCount, behindCount } = options.syncCounts ?? branchSyncCounts(branch, snapshot); + const hasUpstream = branchHasUpstream(branch, snapshot); + const attention = options.attention ?? branchAttention(branch, snapshot); + const syncState = options.syncState ?? branchSyncState(branch, snapshot); + const switchKey = `branch-switch:${branch.name}`; + const syncKey = options.syncActionKey ?? `branch-sync:${branch.name}`; + const fetchKey = options.fetchActionKey ?? `branch-fetch:${branch.name}`; + const deleteKey = `branch-delete:${branch.name}`; + const undoKey = `branch-undo-latest:${branch.name}`; + const mergeKey = `branch-merge:${branch.name}`; + const rebaseKey = `rebase-current:${branch.name}`; + const syncLabel = options.syncLabel ?? branchSyncActionLabel(syncState); + const relativeDate = formatRelativeDate(branch.lastActivityAt); + const checkedOut = branchIsCheckedOut(branch); + const switchDisabled = checkedOut || isActionRunning(switchKey); + const syncDisabled = isActionRunning(syncKey) || isActionRunning(fetchKey); + const deleteDisabled = checkedOut || isActionRunning(deleteKey); + const runSync = (event?: ReactMouseEvent) => + options.onSync + ? options.onSync(event) + : event + ? syncBranch(branch, event) + : runBranchSync(branch); + return ( +
+ + toggleBranchTree(key, branch, options.compareBaseRef, detailsKey)} + onKeyDown={(event) => + toggleBranchTreeFromKeyboard( + key, + branch, + event, + options.compareBaseRef, + detailsKey, + ) + } + onContextMenu={(event) => + openContextMenu( + event, + [ + { id: "switch", label: "Checkout", disabled: switchDisabled }, + { id: "sync", label: syncLabel, disabled: syncDisabled }, + ...(current && aheadCount > 0 + ? [ + { + id: "undo-latest", + label: "Undo latest commit", + disabled: isActionRunning(undoKey), + }, + ] + : []), + ...(!current + ? [ + { + id: "merge", + label: "Merge branch into current", + disabled: isActionRunning(mergeKey), + }, + { + id: "rebase", + label: "Rebase current branch onto branch", + disabled: isActionRunning(rebaseKey), + }, + ] + : []), + { + id: "delete", + label: "Delete branch", + destructive: true, + disabled: deleteDisabled, + icon: "trash", + separatorBefore: true, + }, + { + id: "copy-branch-name", + label: "Copy branch name", + icon: "copy", + separatorBefore: true, + }, + ], + { + switch: () => switchRef(branch.name), + sync: () => runSync(), + delete: () => deleteBranch(branch, false), + "undo-latest": () => undoCommit(branch.name), + merge: () => mergeBranchIntoCurrent(branch.name), + rebase: () => rebaseCurrentOnto(branch.name), + "copy-branch-name": () => copyText(branch.name), + }, + ) + } + > + 0 && behindCount > 0 + ? "Ahead and behind" + : behindCount > 0 + ? "Behind" + : aheadCount > 0 + ? "Ahead" + : !hasUpstream + ? "Unpublished" + : "Up to date" + } + icon={} + activityLabel={panelItemActivity( + controller.runningActions, + [switchKey, syncKey, fetchKey, deleteKey, undoKey, mergeKey, rebaseKey], + [`commit-undo:${branch.name}:`], + )} + title={branch.name} + metadata={ + <> + {hasUpstream && aheadCount === 0 && behindCount === 0 ? ( + + + + ) : null} + {!hasUpstream ? local : null} + {options.secondaryBadge} + {current ? current : null} + {branch.isDefault ? default : null} + {branch.worktreePath && !current ? ( + worktree + ) : null} + + } + trailingMetadata={ + <> + {options.pullRequestBadge} + + + } + time={relativeDate} + actions={ + <> + void switchRef(branch.name)} + > + + + runSync(event)} + > + + + deleteBranch(branch, isActionForced(event))} + > + + + {current && aheadCount > 0 ? ( + undoCommit(branch.name)} + > + + + ) : null} + {!current ? ( + <> + mergeBranchIntoCurrent(branch.name)} + > + + + rebaseCurrentOnto(branch.name)} + > + + + + ) : null} + + } + /> +
+ } + /> + + + {expanded && details ? ( +
{renderBranchTree(branch, details, detailsKey)}
+ ) : null} + {expanded && !details && loadingDetails ? ( +
Loading...
+ ) : null} + + ); + }; + + const remoteBranchRow = (branch: VcsRef, displayName: string, hasLocalBranch: boolean) => { + const details = branchDetailsByRef.get(branch.name); + const key = treeKey("remote-branch", `${branch.remoteName ?? "local"}:${displayName}`); + const expanded = expandedTree.has(key); + const loadingDetails = loadingBranchDetails.has(branch.name); + const current = branch.current; + const relativeDate = formatRelativeDate(branch.lastActivityAt); + const { aheadCount, behindCount } = branchSyncCounts(branch, snapshot); + const hasUpstream = branchHasUpstream(branch, snapshot); + const syncState = branchSyncState(branch, snapshot); + const switchKey = `branch-switch:${branch.name}`; + const syncKey = `branch-sync:${branch.name}`; + const fetchKey = `branch-fetch:${branch.name}`; + const deleteKey = `branch-delete:${branch.name}`; + const undoKey = `branch-undo-latest:${branch.name}`; + const mergeKey = `branch-merge:${branch.name}`; + const rebaseKey = `rebase-current:${branch.name}`; + const checkedOut = hasLocalBranch && branchIsCheckedOut(branch); + const switchDisabled = checkedOut || isActionRunning(switchKey); + const syncLabel = hasLocalBranch ? branchSyncActionLabel(syncState) : "Fetch branch"; + const syncDisabled = hasLocalBranch + ? isActionRunning(syncKey) || isActionRunning(fetchKey) + : isActionRunning(fetchKey); + const deleteDisabled = checkedOut || isActionRunning(deleteKey); + const fetchRemoteBranch = () => + void runAction( + fetchKey, + "Fetching", + () => api?.vcs.fetchBranch({ cwd, branchName: branch.name }) ?? Promise.resolve(), + ); + return ( +
+ + toggleBranchTree(key, branch)} + onKeyDown={(event) => toggleBranchTreeFromKeyboard(key, branch, event)} + onContextMenu={(event) => + openContextMenu( + event, + [ + { id: "switch", label: "Checkout", disabled: switchDisabled }, + { id: "sync", label: syncLabel, disabled: syncDisabled }, + ...(current && aheadCount > 0 + ? [ + { + id: "undo-latest", + label: "Undo latest commit", + disabled: isActionRunning(undoKey), + }, + ] + : []), + ...(!current + ? [ + { + id: "merge", + label: "Merge branch into current", + disabled: isActionRunning(mergeKey), + }, + { + id: "rebase", + label: "Rebase current branch onto branch", + disabled: isActionRunning(rebaseKey), + }, + ] + : []), + { + id: "delete", + label: hasLocalBranch ? "Delete branch" : "Delete remote branch", + destructive: true, + disabled: deleteDisabled, + icon: "trash", + separatorBefore: true, + }, + { + id: "copy-branch-name", + label: "Copy branch name", + icon: "copy", + separatorBefore: true, + }, + ], + { + switch: () => switchRef(branch.name), + sync: () => (hasLocalBranch ? runBranchSync(branch) : fetchRemoteBranch()), + delete: () => deleteBranch(branch, false), + "undo-latest": () => undoCommit(branch.name), + merge: () => mergeBranchIntoCurrent(branch.name), + rebase: () => rebaseCurrentOnto(branch.name), + "copy-branch-name": () => copyText(displayName), + }, + ) + } + > + {expanded ? ( + + ) : ( + + )} + {hasLocalBranch ? ( + + ) : ( + + )} + {displayName} +
+ {hasLocalBranch && !hasUpstream ? local : null} + {current ? current : null} + {branch.isDefault ? default : null} + + {relativeDate ? ( + + {relativeDate} + + ) : null} +
+ + void switchRef(branch.name)} + > + + + {hasLocalBranch ? ( + syncBranch(branch, event)} + > + + + ) : ( + + + + )} + + deleteBranch(branch, hasLocalBranch && isActionForced(event)) + } + > + + + {current && aheadCount > 0 ? ( + undoCommit(branch.name)} + > + + + ) : null} + {!current ? ( + <> + mergeBranchIntoCurrent(branch.name)} + > + + + rebaseCurrentOnto(branch.name)} + > + + + + ) : null} + +
+ } + /> + + + {expanded && details ? ( +
+ {renderBranchTree(branch, details, branch.name)} +
+ ) : null} + {expanded && !details && loadingDetails ? ( +
+ Loading... +
+ ) : null} + + ); + }; + + return { branchRow, remoteBranchRow, renderCommit }; +} diff --git a/apps/web/src/components/source-control/SourceControlPanelCache.ts b/apps/web/src/components/source-control/SourceControlPanelCache.ts new file mode 100644 index 000000000000..e3d0107c6f24 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelCache.ts @@ -0,0 +1,108 @@ +import type { + EnvironmentId, + ThreadId, + VcsPanelBranchDetails, + VcsPanelFileChange, + VcsPanelSnapshotResult, + VcsPanelStashDetails, +} from "@t3tools/contracts"; + +import type { PanelFileDiffLoadState } from "./SourceControlPanel.logic"; + +export type SourceControlSectionKey = "work" | "remotes"; + +export interface CachedSourceControlPanelState { + readonly snapshot: VcsPanelSnapshotResult | null; + readonly snapshotFingerprint: string | null; + readonly collapsed: ReadonlySet; + readonly sectionWeights: Record; + readonly expandedTree: ReadonlySet; + readonly collapsedDefaultTree: ReadonlySet; + readonly branchDetailsByRef: ReadonlyMap; + readonly compareBaseOverrides: ReadonlyMap; + readonly stashDetailsByKey: ReadonlyMap; + readonly expandedFileDiffs: ReadonlySet; + readonly fileDiffsByKey: ReadonlyMap; + readonly enrichedWorkingTreeFilesByPath: ReadonlyMap; + readonly hiddenWorkingTreePaths: ReadonlySet; + readonly selectedChangePaths: ReadonlySet; + readonly selectedWorktreeChangePaths: ReadonlyMap>; +} + +const PANEL_STATE_CACHE_LIMIT = 24; +const sourceControlPanelStateCache = new Map(); + +export function sourceControlPanelStateCacheKey(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly cwd: string; + readonly worktreePath: string | null; +}): string { + return `${input.environmentId}:${input.threadId}:${input.cwd}:${input.worktreePath ?? ""}`; +} + +function cloneReadonlySet(value: ReadonlySet): ReadonlySet { + return new Set(value); +} + +function cloneStringSetMap( + value: ReadonlyMap>, +): ReadonlyMap> { + return new Map([...value].map(([key, set]) => [key, new Set(set)])); +} + +function cacheableFileDiffs( + value: ReadonlyMap, +): ReadonlyMap { + return new Map([...value].filter(([, state]) => state.status !== "loading")); +} + +function cacheableExpandedFileDiffs( + expanded: ReadonlySet, + fileDiffsByKey: ReadonlyMap, +): ReadonlySet { + return new Set([...expanded].filter((key) => fileDiffsByKey.has(key))); +} + +function cloneCachedSourceControlPanelState( + value: CachedSourceControlPanelState, +): CachedSourceControlPanelState { + const fileDiffsByKey = cacheableFileDiffs(value.fileDiffsByKey); + return { + snapshot: value.snapshot, + snapshotFingerprint: value.snapshotFingerprint, + collapsed: cloneReadonlySet(value.collapsed), + sectionWeights: { ...value.sectionWeights }, + expandedTree: cloneReadonlySet(value.expandedTree), + collapsedDefaultTree: cloneReadonlySet(value.collapsedDefaultTree), + branchDetailsByRef: new Map(value.branchDetailsByRef), + compareBaseOverrides: new Map(value.compareBaseOverrides), + stashDetailsByKey: new Map(value.stashDetailsByKey), + expandedFileDiffs: cacheableExpandedFileDiffs(value.expandedFileDiffs, fileDiffsByKey), + fileDiffsByKey, + enrichedWorkingTreeFilesByPath: new Map(value.enrichedWorkingTreeFilesByPath), + hiddenWorkingTreePaths: cloneReadonlySet(value.hiddenWorkingTreePaths), + selectedChangePaths: cloneReadonlySet(value.selectedChangePaths), + selectedWorktreeChangePaths: cloneStringSetMap(value.selectedWorktreeChangePaths), + }; +} + +export function readCachedSourceControlPanelState( + key: string, +): CachedSourceControlPanelState | null { + const cached = sourceControlPanelStateCache.get(key); + return cached ? cloneCachedSourceControlPanelState(cached) : null; +} + +export function writeCachedSourceControlPanelState( + key: string, + value: CachedSourceControlPanelState, +): void { + sourceControlPanelStateCache.delete(key); + sourceControlPanelStateCache.set(key, cloneCachedSourceControlPanelState(value)); + while (sourceControlPanelStateCache.size > PANEL_STATE_CACHE_LIMIT) { + const oldestKey = sourceControlPanelStateCache.keys().next().value; + if (oldestKey === undefined) break; + sourceControlPanelStateCache.delete(oldestKey); + } +} diff --git a/apps/web/src/components/source-control/SourceControlPanelModel.ts b/apps/web/src/components/source-control/SourceControlPanelModel.ts new file mode 100644 index 000000000000..3e229906dd42 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelModel.ts @@ -0,0 +1,335 @@ +import type { + VcsPanelBranchDetails, + VcsPanelChangeGroup, + VcsPanelFileChange, + VcsPanelFileDiffInput, + VcsPanelRemote, + VcsPanelSnapshotResult, + VcsPanelStash, + VcsPanelWorktreeChangeSet, + VcsRef, +} from "@t3tools/contracts"; +import type { MouseEvent as ReactMouseEvent } from "react"; + +import type { + AttentionKind, + PanelChangedFile, + PanelFileDiffLoadState, +} from "./SourceControlPanel.logic"; +import { branchHasUpstream, stashIdentityKey } from "./SourceControlPanel.logic"; +import type { SourceControlSectionKey } from "./SourceControlPanelCache"; + +export type FileDiffSource = NonNullable; +export type FileDiffLoadState = PanelFileDiffLoadState; + +export interface WorkingTreeChangeSetView { + readonly id: string; + readonly label: string; + readonly cwd: string; + readonly branchName: string | null; + readonly worktreePath: string | null; + readonly current: boolean; + readonly changeGroups: readonly VcsPanelChangeGroup[]; + readonly files: readonly PanelChangedFile[]; + readonly selectedPaths: ReadonlySet; + readonly activity: number; +} + +export type SectionKey = SourceControlSectionKey; + +export const SECTION_ORDER: readonly SectionKey[] = ["work", "remotes"]; +export const SECTION_TITLES: Record = { + work: "Actionable", + remotes: "Remotes", +}; +export const DEFAULT_SECTION_WEIGHTS: Record = { + work: 3, + remotes: 1.4, +}; +export const COLLAPSED_SECTION_HEIGHT = 32; +export const MIN_SECTION_WEIGHT = 0.35; +export const COMMIT_PAGE_SIZE = 10; +export const WORKING_FILE_PREFETCH_MARGIN = 600; + +const ENRICHMENT_KEY_SEPARATOR = "\0"; +const readableDateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Source control action failed."; +} + +export function sourceControlPanelError( + refreshError: string | null, + mutationError: string | null, +): string | null { + return mutationError ?? refreshError; +} + +export function applyWorkingTreeFileEnrichment( + groups: readonly VcsPanelChangeGroup[], + targetCwd: string, + enrichedFilesByPath: ReadonlyMap, + hiddenPaths: ReadonlySet, +): VcsPanelChangeGroup[] { + if (enrichedFilesByPath.size === 0 && hiddenPaths.size === 0) { + return groups.map((group) => ({ ...group, files: [...group.files] })); + } + return groups.map((group) => { + if (group.kind !== "unstaged") return { ...group, files: [...group.files] }; + const seenPaths = new Set(); + const files = group.files.flatMap((file) => { + const key = enrichmentFileKey(targetCwd, file.path); + if (hiddenPaths.has(key)) return []; + const enrichedFile = enrichedFilesByPath.get(key) ?? file; + seenPaths.add(enrichedFile.path); + return [enrichedFile]; + }); + for (const [key, enrichedFile] of enrichedFilesByPath) { + const parsed = splitEnrichmentFileKey(key); + if (parsed.cwd !== targetCwd) continue; + if (seenPaths.has(enrichedFile.path) || hiddenPaths.has(key)) continue; + files.push(enrichedFile); + } + return { + ...group, + files: files.toSorted((left, right) => left.path.localeCompare(right.path)), + }; + }); +} + +export function shouldEnrichWorkingTreeFile(file: PanelChangedFile): boolean { + return file.hasUnstagedChanges && (file.status === "untracked" || file.status === "deleted"); +} + +export function enrichmentFileKey(cwd: string, path: string): string { + return `${cwd}${ENRICHMENT_KEY_SEPARATOR}${path}`; +} + +export function splitEnrichmentFileKey(key: string): { + readonly cwd: string; + readonly path: string; +} { + const separatorIndex = key.indexOf(ENRICHMENT_KEY_SEPARATOR); + if (separatorIndex < 0) return { cwd: "", path: key }; + return { + cwd: key.slice(0, separatorIndex), + path: key.slice(separatorIndex + ENRICHMENT_KEY_SEPARATOR.length), + }; +} + +export function isActionForced(event: ReactMouseEvent): boolean { + return event.shiftKey; +} + +export function shouldFetchBeforePull(event: ReactMouseEvent): boolean { + return event.altKey; +} + +export function commitUndoActionKey(branchName: string, sha?: string): string { + return sha ? `commit-undo:${branchName}:${sha}` : `branch-undo-latest:${branchName}`; +} + +export function treeKey(kind: string, id: string): string { + return `${kind}:${id}`; +} + +export function formatReadableDate(value: string | null | undefined): string | null { + if (!value) return null; + const time = Date.parse(value); + if (!Number.isFinite(time)) return null; + return readableDateFormatter.format(new Date(time)); +} + +export function sumFiles(files: readonly VcsPanelFileChange[]) { + return files.reduce( + (total, file) => ({ + insertions: total.insertions + file.insertions, + deletions: total.deletions + file.deletions, + }), + { insertions: 0, deletions: 0 }, + ); +} + +export function fileBasename(path: string): string { + const parts = path.split(/[\\/]/); + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index]; + if (part) return part; + } + return path; +} + +export function uniquePaths(paths: readonly string[]): string[] { + return [...new Set(paths.filter((path) => path.length > 0))]; +} + +export function renameOriginalPathForFile( + file: Pick, +): string | undefined { + return file.status === "renamed" && file.originalPath ? file.originalPath : undefined; +} + +export function operationPathsForFile( + file: Pick, +): string[] { + const originalPath = renameOriginalPathForFile(file); + return uniquePaths(originalPath ? [file.path, originalPath] : [file.path]); +} + +export function worktreeChangeSetId( + changeSet: Pick, +): string { + return `worktree:${changeSet.worktreePath}`; +} + +export function changeSetAttention(files: readonly PanelChangedFile[]): AttentionKind { + return files.some((file) => file.hasConflicts) + ? "conflicts" + : files.length > 0 + ? "dirty" + : "stale"; +} + +export function commitCountLabel(count: number): string { + return count === 1 ? "1 commit" : `${count} commits`; +} + +export function stashBranchName(stash: VcsPanelStash): string | null { + return /^(?:WIP\s+)?on\s+([^:]+):/i.exec(stash.message)?.[1]?.trim() ?? null; +} + +export function branchActivityTimestamp(branch: { + readonly lastActivityAt?: string | null | undefined; +}): number { + if (!branch.lastActivityAt) return 0; + const time = Date.parse(branch.lastActivityAt); + return Number.isFinite(time) ? time : 0; +} + +export function mapBranchDetails( + details: readonly VcsPanelBranchDetails[], +): ReadonlyMap { + const map = new Map(); + for (const detail of details) { + map.set(detail.fullRefName, detail); + map.set(detail.name, detail); + } + return map; +} + +export function remoteBranchRef( + remote: VcsPanelRemote, + branch: VcsPanelRemote["branches"][number], +): VcsRef { + return { + name: branch.fullRefName, + isRemote: true, + remoteName: remote.name, + current: false, + isDefault: branch.isDefaultRemoteHead, + worktreePath: null, + lastActivityAt: branch.lastActivityAt, + upstreamName: null, + }; +} + +export function localBranchForRemoteBranch( + snapshot: VcsPanelSnapshotResult, + remote: VcsPanelRemote, + branch: VcsPanelRemote["branches"][number], +): VcsRef | null { + return ( + snapshot.localBranches.find( + (localBranch) => + localBranch.upstreamRemoteName === remote.name && + localBranch.upstreamName === branch.fullRefName, + ) ?? + snapshot.localBranches.find( + (localBranch) => + localBranch.name === branch.name && + localBranch.upstreamRemoteName === remote.name && + localBranch.upstreamName === `${remote.name}/${branch.name}`, + ) ?? + null + ); +} + +export function localOnlyBranches(snapshot: VcsPanelSnapshotResult): VcsRef[] { + return snapshot.localBranches + .filter((branch) => !branchHasUpstream(branch, snapshot)) + .toSorted((left, right) => branchActivityTimestamp(right) - branchActivityTimestamp(left)); +} + +export function compareBaseRefNames(snapshot: VcsPanelSnapshotResult | null): string[] { + if (!snapshot) return []; + const refs = new Set(); + if (snapshot.defaultCompareRef) refs.add(snapshot.defaultCompareRef); + for (const branch of snapshot.localBranches) { + refs.add(branch.name); + if (branch.upstreamName) refs.add(branch.upstreamName); + } + for (const remote of snapshot.remotes) { + for (const branch of remote.branches) refs.add(branch.fullRefName); + } + return [...refs].toSorted((left, right) => left.localeCompare(right)); +} + +export interface ExpandedBranchRequest { + readonly branch: VcsRef; + readonly detailsKey: string; + readonly compareBaseRef?: string; +} + +export function expandedBranchesForSnapshot( + snapshot: VcsPanelSnapshotResult, + expanded: ReadonlySet, +): ExpandedBranchRequest[] { + const localBranches = snapshot.localBranches + .filter((branch) => expanded.has(treeKey("branch", branch.name))) + .map((branch) => ({ branch, detailsKey: branch.name })); + const expandedLocalBranches = localOnlyBranches(snapshot) + .filter((branch) => expanded.has(treeKey("remote-branch", `local:${branch.name}`))) + .map((branch) => ({ branch, detailsKey: branch.name })); + const remoteBranches = snapshot.remotes.flatMap((remote) => + remote.branches + .map((branch) => ({ + displayName: branch.name, + ref: + localBranchForRemoteBranch(snapshot, remote, branch) ?? remoteBranchRef(remote, branch), + })) + .filter((branch) => + expanded.has( + treeKey("remote-branch", `${branch.ref.remoteName ?? "local"}:${branch.displayName}`), + ), + ) + .map((branch) => ({ branch: branch.ref, detailsKey: branch.ref.name })), + ); + const forkBranches = snapshot.actionableForkBranches.flatMap((fork) => { + const branch = snapshot.localBranches.find( + (localBranch) => localBranch.name === fork.localBranchName, + ); + if (!branch) return []; + const detailsKey = treeKey("fork-details", `${fork.localBranchName}:${fork.remoteRefName}`); + return expanded.has(treeKey("fork-branch", `${fork.localBranchName}:${fork.remoteRefName}`)) + ? [{ branch, detailsKey, compareBaseRef: fork.remoteRefName }] + : []; + }); + return [...localBranches, ...expandedLocalBranches, ...remoteBranches, ...forkBranches]; +} + +export interface ExpandedStashRequest { + readonly stashRef: string; + readonly detailsKey: string; +} + +export function expandedStashesForSnapshot( + snapshot: VcsPanelSnapshotResult, + expanded: ReadonlySet, +): ExpandedStashRequest[] { + return snapshot.stashes + .filter((stash) => expanded.has(treeKey("stash", stashIdentityKey(stash)))) + .map((stash) => ({ stashRef: stash.refName, detailsKey: stashIdentityKey(stash) })); +} diff --git a/apps/web/src/components/source-control/SourceControlPanelPrimitives.tsx b/apps/web/src/components/source-control/SourceControlPanelPrimitives.tsx new file mode 100644 index 000000000000..09b2a723682c --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelPrimitives.tsx @@ -0,0 +1,686 @@ +import { WorkingIndicator } from "../WorkingIndicator"; +import type { + VcsPanelCommitSummary, + VcsPanelFileChange, + VcsPanelRemote, + VcsPanelStash, + VcsRef, +} from "@t3tools/contracts"; +import { + AlertTriangle, + ChevronDown, + ChevronRight, + Download, + FileText, + GitBranch, + GitCommit, + GitCompare, + LoaderCircle, + RefreshCw, + Tag, + Target, + Upload, +} from "lucide-react"; +import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; +import { useCallback, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; + +import { VisualStudioCode } from "../Icons"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipCardPopup, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + type AttentionKind, + type BranchSyncState, + formatRelativeDate, +} from "./SourceControlPanel.logic"; +import { formatReadableDate, type WorkingTreeChangeSetView } from "./SourceControlPanelModel"; + +export function StatLabels({ + insertions, + deletions, +}: { + readonly insertions: number; + readonly deletions: number; +}) { + if (insertions === 0 && deletions === 0) return null; + return ( + + {insertions > 0 ? +{insertions} : null} + {deletions > 0 ? -{deletions} : null} + + ); +} + +export function BranchSyncLabels({ + aheadCount, + behindCount, +}: { + readonly aheadCount: number; + readonly behindCount: number; +}) { + if (aheadCount === 0 && behindCount === 0) return null; + return ( + + {aheadCount > 0 ? ↑{aheadCount} : null} + {behindCount > 0 ? ↓{behindCount} : null} + + ); +} + +export function branchSyncActionLabel(state: BranchSyncState): string { + switch (state) { + case "publish": + return "Publish"; + case "pull": + return "Pull. Shift: reset. Option: fetch."; + case "push": + return "Push"; + case "diverged": + return "Sync diverged"; + case "fetch": + return "Fetch"; + } +} + +export function BranchSyncActionIcon({ state }: { readonly state: BranchSyncState }) { + switch (state) { + case "publish": + case "push": + return ; + case "pull": + return ; + case "diverged": + return ; + case "fetch": + return ; + } +} + +export const ATTENTION_RANK: Record = { + conflicts: 0, + diverged: 1, + behind: 2, + unpushed: 3, + dirty: 4, + stale: 5, +}; + +export function stashActivityTimestamp(stash: VcsPanelStash): number { + if (!stash.createdAt) return 0; + const time = Date.parse(stash.createdAt); + return Number.isFinite(time) ? time : 0; +} + +export function AttentionIcon({ kind }: { readonly kind: AttentionKind }) { + switch (kind) { + case "conflicts": + case "diverged": + return ; + case "behind": + return ; + case "unpushed": + return ; + case "dirty": + return ; + case "stale": + return ; + } +} + +export function AuthorAvatar({ + commit, + className, +}: { + readonly commit: VcsPanelCommitSummary; + readonly className?: string; +}) { + const [failed, setFailed] = useState(false); + const fallbackText = + commit.authorName + ?.trim() + .split(/\s+/u) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") || + commit.authorEmail?.trim()[0]?.toUpperCase() || + "?"; + const avatarClassName = cn( + "inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[9px] font-medium text-muted-foreground object-cover", + className, + ); + if (!commit.authorAvatarUrl || failed) { + return ( + + {fallbackText} + + ); + } + return ( + {commit.authorName setFailed(true)} + /> + ); +} + +type DisplayHeadRef = + | { readonly kind: "local"; readonly name: string; readonly synced: boolean } + | { readonly kind: "remote"; readonly name: string }; + +function displayHeadRefs( + headRefs: readonly string[], + remoteNames: readonly string[] = [], +): DisplayHeadRef[] { + const remoteNameSet = new Set(remoteNames); + const remoteRefParts = ( + ref: string, + ): { readonly remoteName: string; readonly branchName: string } | null => { + const slashIndex = ref.indexOf("/"); + if (slashIndex <= 0) return null; + const remoteName = ref.slice(0, slashIndex); + if (!remoteNameSet.has(remoteName)) return null; + const branchName = ref.slice(slashIndex + 1); + if (branchName.length === 0 || branchName === "HEAD") return null; + return { remoteName, branchName }; + }; + const localRefs = new Set(headRefs.filter((ref) => remoteRefParts(ref) === null)); + const remoteByBranch = new Map(); + for (const ref of headRefs) { + const parsed = remoteRefParts(ref); + if (parsed) remoteByBranch.set(parsed.branchName, ref); + } + const refs: DisplayHeadRef[] = [...localRefs] + .toSorted((left, right) => left.localeCompare(right)) + .map((name) => ({ kind: "local", name, synced: remoteByBranch.has(name) })); + for (const branchName of [...remoteByBranch.keys()].toSorted((left, right) => + left.localeCompare(right), + )) { + if (!localRefs.has(branchName)) refs.push({ kind: "remote", name: branchName }); + } + return refs; +} + +export function SyncedIcon({ className }: { readonly className?: string }) { + return ; +} + +export function CompactBadge({ children }: { readonly children: ReactNode }) { + return ( + + {children} + + ); +} + +export function RefLabels({ + commit, + remoteNames, +}: { + readonly commit: VcsPanelCommitSummary; + readonly remoteNames?: readonly string[] | undefined; +}) { + const headRefs = displayHeadRefs(commit.headRefs, remoteNames); + if (headRefs.length === 0 && commit.tags.length === 0) return null; + return ( + + {headRefs.map((ref) => ( + + + {ref.kind === "remote" || (ref.kind === "local" && ref.synced) ? : null} + {ref.name} + + + ))} + {commit.tags.map((tag) => ( + + + + {tag} + + + ))} + + ); +} + +function sumFiles(files: readonly VcsPanelFileChange[]) { + return files.reduce( + (sum, file) => ({ + insertions: sum.insertions + file.insertions, + deletions: sum.deletions + file.deletions, + }), + { insertions: 0, deletions: 0 }, + ); +} + +export function CommitTooltip({ + commit, + remoteNames, +}: { + readonly commit: VcsPanelCommitSummary; + readonly remoteNames?: readonly string[] | undefined; +}) { + const relativeDate = formatRelativeDate(commit.authoredAt); + const readableDate = formatReadableDate(commit.authoredAt); + const stats = commit.fileStats ?? sumFiles(commit.files); + return ( +
+
+ +
+
{commit.authorName ?? "Unknown author"}
+
+ {commit.shortSha} +
+
+
+ {relativeDate || readableDate ? ( +
+ {relativeDate ?? "Unknown time"} + {readableDate ? ` (${readableDate})` : null} +
+ ) : null} +
{commit.message}
+ {commit.fileStats || !commit.filesDeferred ? ( + + ) : null} + +
+ ); +} + +function fileTooltipAnchor(trigger: HTMLElement | null) { + if (!trigger) return null; + const panel = trigger.closest("[data-source-control-tooltip-boundary]"); + if (!panel) return trigger; + return { + contextElement: trigger, + getBoundingClientRect: () => { + const triggerRect = trigger.getBoundingClientRect(); + const panelRect = panel.getBoundingClientRect(); + const left = panelRect.left + 8; + return DOMRect.fromRect({ + x: left, + y: triggerRect.top, + width: Math.max(0, triggerRect.right - left), + height: triggerRect.height, + }); + }, + }; +} + +function TooltipMetadataRow({ label, value }: { readonly label: string; readonly value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function FilePathTooltip({ + file, + anchor, +}: { + readonly file: VcsPanelFileChange; + readonly anchor: () => ReturnType; +}) { + return ( + +
+
{file.path}
+ {file.originalPath && file.originalPath !== file.path ? ( + + ) : null} + +
+
+ ); +} + +export function WorkingFileTooltipRow({ + children, + file, + onContextMenu, + onToggle, +}: { + readonly children: ReactNode; + readonly file: VcsPanelFileChange; + readonly onContextMenu: (event: ReactMouseEvent) => void; + readonly onToggle: () => void; +}) { + const triggerRef = useRef(null); + const tooltipAnchor = useCallback(() => fileTooltipAnchor(triggerRef.current), []); + return ( + + { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onToggle(); + }} + onContextMenu={onContextMenu} + /> + } + > + {children} + + + + ); +} + +export function fileStatusLetter(status: VcsPanelFileChange["status"]): string { + switch (status) { + case "added": + case "untracked": + return "A"; + case "deleted": + return "D"; + case "renamed": + return "R"; + case "copied": + return "C"; + case "conflicted": + return "U"; + case "modified": + return "M"; + } +} + +export function fileStatusColor(status: VcsPanelFileChange["status"]): string { + switch (status) { + case "added": + case "untracked": + return "text-success-foreground"; + case "deleted": + case "conflicted": + return "text-destructive-foreground"; + default: + return "text-muted-foreground"; + } +} + +export function FileChangeTooltipRow({ + expanded, + file, + onFileContextMenu, + onFileToggle, + onOpenFile, + onOpenInVsCode, +}: { + readonly expanded: boolean; + readonly file: VcsPanelFileChange; + readonly onFileContextMenu?: + | ((event: ReactMouseEvent, file: VcsPanelFileChange) => void) + | undefined; + readonly onFileToggle?: ((file: VcsPanelFileChange) => void) | undefined; + readonly onOpenFile?: ((file: VcsPanelFileChange) => void) | undefined; + readonly onOpenInVsCode?: ((file: VcsPanelFileChange) => void) | undefined; +}) { + const triggerRef = useRef(null); + const tooltipAnchor = useCallback(() => fileTooltipAnchor(triggerRef.current), []); + return ( + + onFileToggle(file) : undefined} + onKeyDown={ + onFileToggle + ? (event) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onFileToggle(file); + } + : undefined + } + onContextMenu={ + onFileContextMenu ? (event) => onFileContextMenu(event, file) : undefined + } + /> + } + > + {onFileToggle ? ( + expanded ? ( + + ) : ( + + ) + ) : null} + + {fileStatusLetter(file.status)} + + {file.path} + + {onOpenFile || onOpenInVsCode ? ( + + {onOpenFile ? ( + onOpenFile(file)}> + + + ) : null} + {onOpenInVsCode ? ( + onOpenInVsCode(file)}> + + + ) : null} + + ) : null} + + + + ); +} + +export function BranchTooltip({ + branch, + displayName, + aheadCount, + behindCount, +}: { + readonly branch: VcsRef; + readonly displayName: string; + readonly aheadCount: number; + readonly behindCount: number; +}) { + const relativeDate = formatRelativeDate(branch.lastActivityAt); + const readableDate = formatReadableDate(branch.lastActivityAt); + return ( + +
+
{displayName}
+
+ {branch.name !== displayName ? ( + + ) : null} + {branch.upstreamName ? ( + + ) : null} + {branch.worktreePath ? ( + + ) : null} + {branch.current || branch.isDefault || aheadCount > 0 || behindCount > 0 ? ( +
+ {branch.current ? current : null} + {branch.isDefault ? default : null} + +
+ ) : null} + {relativeDate || readableDate ? ( +
+ {relativeDate ?? "Unknown time"} + {readableDate ? ` (${readableDate})` : null} +
+ ) : null} +
+
+
+ ); +} + +export function WorkingTreeTooltip({ + changeSet, +}: { + readonly changeSet: WorkingTreeChangeSetView; +}) { + const stats = sumFiles(changeSet.files); + return ( + +
+
+ {changeSet.current ? "Working tree" : "Sibling working tree"} +
+
+ + +
+ + {changeSet.files.length === 1 ? "1 file" : `${changeSet.files.length} files`} + + +
+
+
+
+ ); +} + +export function RemoteTooltip({ remote }: { readonly remote: VcsPanelRemote }) { + return ( + +
+
{remote.name}
+
+ + {remote.pushUrl && remote.pushUrl !== remote.fetchUrl ? ( + + ) : null} +
+ {remote.branches.length === 1 ? "1 branch" : `${remote.branches.length} branches`} +
+
+
+
+ ); +} + +export function StashTooltip({ + stash, + branchName, +}: { + readonly stash: VcsPanelStash; + readonly branchName: string | null; +}) { + const relativeDate = formatRelativeDate(stash.createdAt); + const readableDate = formatReadableDate(stash.createdAt); + return ( + +
+
{stash.message}
+
+
+ Ref {stash.refName} +
+ {branchName ? : null} + {relativeDate || readableDate ? ( +
+ {relativeDate ?? "Unknown time"} + {readableDate ? ` (${readableDate})` : null} +
+ ) : null} +
+
+
+ ); +} + +export function IconButton({ + label, + children, + disabled, + destructive, + loading, + onClick, +}: { + readonly label: string; + readonly children: ReactNode; + readonly disabled?: boolean; + readonly destructive?: boolean; + readonly loading?: boolean; + readonly onClick?: (event: ReactMouseEvent) => void; +}) { + return ( + + + {loading ? : children} + + } + /> + {label} + + ); +} + +export function RowActions({ + children, + activityLabel, +}: { + readonly children: ReactNode; + readonly activityLabel?: string | null | undefined; +}) { + if (activityLabel) { + return ( +
+ +
+ ); + } + return ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {children} +
+ ); +} diff --git a/apps/web/src/components/source-control/SourceControlPanelRepositories.tsx b/apps/web/src/components/source-control/SourceControlPanelRepositories.tsx new file mode 100644 index 000000000000..8a02819b7835 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelRepositories.tsx @@ -0,0 +1,448 @@ +import { panelItemActivity } from "./SourceControlPanel.logic"; +import { + actionableItemClassName, + SourceControlActionableItem, +} from "./SourceControlActionableItem"; +import type { + EnvironmentId, + ThreadId, + VcsPanelChangeGroup, + VcsPanelFileDiffInput, + VcsPanelRemote, + VcsPanelStash, + VcsPanelWorktreeChangeSet, + VcsRef, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + Archive, + ChevronDown, + ChevronRight, + GitBranch, + GitCommit, + RefreshCw, + Trash2, +} from "lucide-react"; +import { + useCallback, + useDeferredValue, + useEffect, + useId, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { getRenderablePatch, resolveDiffThemeName } from "~/lib/diffRendering"; +import { cn } from "~/lib/utils"; +import { useEnvironmentQuery } from "~/state/query"; +import { + isSourceControlPanelCommandInterrupted, + resolveSourceControlPanelPresentationState, + useSourceControlPanelApi, +} from "~/state/sourceControlPanel"; +import { resolvePathLinkTarget } from "~/terminal-links"; + +import { Badge } from "../ui/badge"; +import { Checkbox } from "../ui/checkbox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Textarea } from "../ui/textarea"; +import { Tooltip, TooltipTrigger } from "../ui/tooltip"; +import { + beginPanelFileDiffLoad, + branchHasUpstream, + branchOperationCwd, + branchSyncState, + drainPanelRefreshQueue, + namedBranchOperationCwd, + type PanelChangedFile, + stashIdentityKey, +} from "./SourceControlPanel.logic"; +import { + COMMIT_PAGE_SIZE, + MIN_SECTION_WEIGHT, + SECTION_TITLES, + applyWorkingTreeFileEnrichment, + commitUndoActionKey, + enrichmentFileKey, + expandedBranchesForSnapshot, + fileBasename, + isActionForced, + localBranchForRemoteBranch, + mapBranchDetails, + remoteBranchRef, + shouldFetchBeforePull, + stashBranchName, + splitEnrichmentFileKey, + treeKey, + worktreeChangeSetId, + commitCountLabel, + type FileDiffSource, + type WorkingTreeChangeSetView, +} from "./SourceControlPanelModel"; +import { + AttentionIcon, + BranchSyncLabels, + CommitTooltip, + FileChangeTooltipRow, + IconButton, + RemoteTooltip, + RowActions, + StashTooltip, + WorkingFileTooltipRow, + branchSyncActionLabel, +} from "./SourceControlPanelPrimitives"; +import type { ReadySourceControlPanelController } from "./useSourceControlPanelController"; +import type { makeSourceControlPanelBranchRenderers } from "./SourceControlPanelBranches"; +import { Download } from "lucide-react"; +import { formatRelativeDate } from "./SourceControlPanel.logic"; +import { FileChangeList } from "./SourceControlPanelRows"; + +type BranchRenderers = ReturnType; + +export function makeSourceControlPanelRepositoryRenderers( + controller: ReadySourceControlPanelController, + branchRenderers: BranchRenderers, +) { + const { + api, + confirm, + copyText, + cwd, + expandedTree, + fileDiffListProps, + isActionRunning, + loadStashDetails, + loadingStashDetails, + openContextMenu, + openFileChangeContextMenu, + openInVsCode, + runAction, + snapshot, + stashDetailsByKey, + toggleFileDiff, + toggleStashTree, + toggleTree, + toggleTreeFromKeyboard, + } = controller; + const { remoteBranchRow } = branchRenderers; + const remoteRow = (remote: VcsPanelRemote) => { + const key = treeKey("remote", remote.name); + const expanded = expandedTree.has(key); + const fetchKey = `remote-fetch:${remote.name}`; + const removeKey = `remote-remove:${remote.name}`; + const fetchRemote = () => + void runAction( + fetchKey, + "Fetching", + () => api?.vcs.fetchRemote({ cwd, remoteName: remote.name }) ?? Promise.resolve(), + ); + const removeRemote = () => + void (async () => { + if (!(await confirm(`Remove remote ${remote.name}?`))) return; + await runAction( + removeKey, + "Removing", + () => api?.vcs.removeRemote({ cwd, remoteName: remote.name }) ?? Promise.resolve(), + ); + })(); + const remoteUrl = remote.fetchUrl ?? remote.pushUrl ?? ""; + return ( +
+
toggleTree(key)} + onKeyDown={(event) => toggleTreeFromKeyboard(key, event)} + onContextMenu={(event) => + openContextMenu( + event, + [ + { id: "fetch", label: "Fetch remote", disabled: isActionRunning(fetchKey) }, + { + id: "remove", + label: "Remove remote", + destructive: true, + disabled: isActionRunning(removeKey), + icon: "trash", + separatorBefore: true, + }, + { id: "copy-name", label: "Copy name", icon: "copy", separatorBefore: true }, + { id: "copy-url", label: "Copy url", disabled: !remoteUrl, icon: "copy" }, + ], + { + fetch: fetchRemote, + remove: removeRemote, + "copy-name": () => copyText(remote.name), + "copy-url": () => copyText(remoteUrl, "Remote URL unavailable."), + }, + ) + } + > + {expanded ? ( + + ) : ( + + )} + + }> + {remote.name} + + + + + {remote.fetchUrl ?? "No fetch URL"} + + + + + + + + + +
+ {expanded ? ( +
+ {remote.branches.length === 0 ? ( +
No remote branches.
+ ) : ( + remote.branches.map((branch) => { + const localBranch = localBranchForRemoteBranch(snapshot, remote, branch); + return remoteBranchRow( + localBranch ?? remoteBranchRef(remote, branch), + branch.name, + localBranch !== null, + ); + }) + )} +
+ ) : null} +
+ ); + }; + + const localBranchesRow = (branches: readonly VcsRef[]) => { + const key = treeKey("unpublished", "local"); + const expanded = expandedTree.has(key); + return ( +
+
toggleTree(key)} + onKeyDown={(event) => toggleTreeFromKeyboard(key, event)} + onContextMenu={(event) => + openContextMenu(event, [{ id: "copy-name", label: "Copy name", icon: "copy" }], { + "copy-name": () => copyText("unpublished"), + }) + } + > + {expanded ? ( + + ) : ( + + )} + unpublished + + {branches.length === 1 ? "1 branch" : `${branches.length} branches`} + +
+ {expanded ? ( +
+ {branches.map((branch) => remoteBranchRow(branch, branch.name, true))} +
+ ) : null} +
+ ); + }; + + const stashRow = (stash: VcsPanelStash) => { + const stashKey = stashIdentityKey(stash); + const key = treeKey("stash", stashKey); + const expanded = expandedTree.has(key); + const details = stashDetailsByKey.get(stashKey); + const loadingDetails = loadingStashDetails.has(stashKey); + const mutationKey = `stash-mutation:${stashKey}`; + const mutationRunning = [...controller.runningActions.keys()].some((key) => + key.startsWith("stash-mutation:"), + ); + const relativeDate = formatRelativeDate(stash.createdAt); + const branchName = stashBranchName(stash); + const applyStash = () => + void runAction( + mutationKey, + "Applying", + () => + api?.vcs.applyStash({ cwd, expectedSha: stash.sha, stashRef: stash.refName }) ?? + Promise.resolve(), + ); + const popStash = () => + void runAction( + mutationKey, + "Popping", + () => + api?.vcs.popStash({ cwd, expectedSha: stash.sha, stashRef: stash.refName }) ?? + Promise.resolve(), + ); + const dropStash = () => + void (async () => { + if (!(await confirm(`Drop ${stash.refName}?`))) return; + await runAction( + mutationKey, + "Dropping", + () => + api?.vcs.dropStash({ cwd, expectedSha: stash.sha, stashRef: stash.refName }) ?? + Promise.resolve(), + ); + })(); + return ( +
+
toggleStashTree(key, stash)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + toggleStashTree(key, stash); + }} + onContextMenu={(event) => + openContextMenu( + event, + [ + { id: "apply", label: "Apply stash", disabled: mutationRunning }, + { id: "pop", label: "Pop stash", disabled: mutationRunning }, + { + id: "drop", + label: "Drop stash", + destructive: true, + disabled: mutationRunning, + icon: "trash", + separatorBefore: true, + }, + { + id: "copy-stash-name", + label: "Copy stash name", + icon: "copy", + separatorBefore: true, + }, + { + id: "copy-branch-name", + label: "Copy branch name", + disabled: !branchName, + icon: "copy", + }, + ], + { + apply: applyStash, + pop: popStash, + drop: dropStash, + "copy-stash-name": () => copyText(stash.refName), + "copy-branch-name": () => copyText(branchName ?? "", "Stash branch unavailable."), + }, + ) + } + > + } + title={ + + }> + {stash.message} + + + + } + metadata={ + <> + {stash.refName} + {branchName ? {branchName} : null} + + } + time={relativeDate} + actions={ + <> + + + + + + + + + + + } + /> +
+ {expanded && details ? ( +
+ ({ + kind: "stash", + stashRef: stash.refName, + }))} + /> +
+ ) : null} + {expanded && !details && loadingDetails ? ( +
Loading...
+ ) : null} +
+ ); + }; + + return { localBranchesRow, remoteRow, stashRow }; +} diff --git a/apps/web/src/components/source-control/SourceControlPanelRows.test.tsx b/apps/web/src/components/source-control/SourceControlPanelRows.test.tsx new file mode 100644 index 000000000000..facda31e68a8 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelRows.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it } from "@effect/vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { LoadMoreCommitsButton } from "./SourceControlPanelRows"; + +describe("LoadMoreCommitsButton", () => { + it("shows progress and disables repeated clicks while commits load", () => { + const markup = renderToStaticMarkup( + {}} />, + ); + + expect(markup).toContain("Loading..."); + expect(markup).toContain('disabled=""'); + expect(markup).not.toContain("Load 10 more"); + }); + + it("describes the next page before loading starts", () => { + const markup = renderToStaticMarkup( + {}} />, + ); + + expect(markup).toContain("Load 10 more of 24 remaining"); + expect(markup).not.toContain('disabled=""'); + }); +}); diff --git a/apps/web/src/components/source-control/SourceControlPanelRows.tsx b/apps/web/src/components/source-control/SourceControlPanelRows.tsx new file mode 100644 index 000000000000..40b8e4da0fd6 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelRows.tsx @@ -0,0 +1,239 @@ +import { SourceControlVirtualList } from "./SourceControlVirtualList"; +import type { VcsPanelFileChange, VcsPanelSnapshotResult } from "@t3tools/contracts"; +import { FileDiff, useWorkerPool } from "@pierre/diffs/react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import type { ComponentProps, MouseEvent as ReactMouseEvent, ReactNode } from "react"; +import { useCallback, useMemo, useSyncExternalStore } from "react"; + +import { getRenderablePatch, resolveDiffThemeName } from "~/lib/diffRendering"; + +import { Badge } from "../ui/badge"; +import type { PanelChangedFile } from "./SourceControlPanel.logic"; +import { + COLLAPSED_SECTION_HEIGHT, + COMMIT_PAGE_SIZE, + sumFiles, + type SectionKey, +} from "./SourceControlPanelModel"; +import { BranchSyncLabels, FileChangeTooltipRow, StatLabels } from "./SourceControlPanelPrimitives"; + +export function CollapsibleSection({ + sectionKey, + title, + collapsed, + weight, + onToggle, + onResizeStart, + children, + action, +}: { + readonly sectionKey: SectionKey; + readonly title: string; + readonly collapsed: boolean; + readonly weight: number; + readonly onToggle: () => void; + readonly onResizeStart: (key: SectionKey, event: ReactMouseEvent) => void; + readonly children: ReactNode; + readonly action?: ReactNode; +}) { + return ( +
+
+ + {action} +
+ {!collapsed ? ( +
+ {children} +
+ ) : null} + {!collapsed ? ( +
onResizeStart(sectionKey, event)} + /> + ) : null} +
+ ); +} + +export function BranchBadge({ snapshot }: { readonly snapshot: VcsPanelSnapshotResult }) { + const status = snapshot.status; + if (!status.hasUpstream) { + return ( + + No upstream + + ); + } + if (status.aheadCount === 0 && status.behindCount === 0) { + return ( + + Synced + + ); + } + return ; +} + +export function FileChangeSummary({ files }: { readonly files: readonly VcsPanelFileChange[] }) { + const stats = sumFiles(files); + return ( + + {files.length === 1 ? "1 file" : `${files.length} files`} + + + ); +} + +export function FileChangeList({ + files, + emptyLabel, + onFileContextMenu, + getFileKey, + isFileExpanded, + onFileToggle, + renderExpandedFile, + onOpenFile, + onOpenInVsCode, +}: { + readonly files: readonly VcsPanelFileChange[]; + readonly emptyLabel: string; + readonly onFileContextMenu?: ( + event: ReactMouseEvent, + file: VcsPanelFileChange, + ) => void; + readonly getFileKey?: (file: VcsPanelFileChange) => string; + readonly isFileExpanded?: (file: VcsPanelFileChange) => boolean; + readonly onFileToggle?: (file: VcsPanelFileChange) => void; + readonly renderExpandedFile?: (file: VcsPanelFileChange) => ReactNode; + readonly onOpenFile?: (file: VcsPanelFileChange) => void; + readonly onOpenInVsCode?: (file: VcsPanelFileChange) => void; +}) { + if (files.length === 0) { + return
{emptyLabel}
; + } + return ( + getFileKey?.(file) ?? `${file.path}:${file.status}`} + renderItem={(file) => { + const fileKey = getFileKey?.(file) ?? `${file.path}:${file.status}`; + const expanded = isFileExpanded?.(file) ?? false; + return ( +
+ + {expanded && renderExpandedFile ? ( +
{renderExpandedFile(file)}
+ ) : null} +
+ ); + }} + /> + ); +} + +export function LoadMoreCommitsButton({ + remaining, + loading, + onClick, +}: { + readonly remaining: number; + readonly loading: boolean; + readonly onClick: () => void; +}) { + if (remaining <= 0) return null; + return ( + + ); +} + +export function InlineFileDiff({ + patch, + resolvedTheme, +}: { + readonly patch: string; + readonly resolvedTheme: "light" | "dark"; +}) { + const renderablePatch = useMemo( + () => getRenderablePatch(patch, `vcs-panel-file:${resolvedTheme}`), + [patch, resolvedTheme], + ); + if (!renderablePatch) { + return
No diff.
; + } + if (renderablePatch.kind === "raw") { + return ( +
+        {renderablePatch.text}
+      
+ ); + } + return ( +
+ {renderablePatch.files.map((fileDiff) => ( + + ))} +
+ ); +} + +function WorkerRefreshedFileDiff(props: ComponentProps) { + const workerPool = useWorkerPool(); + const subscribe = useCallback( + (listener: () => void) => workerPool?.subscribeToStatChanges(listener) ?? (() => {}), + [workerPool], + ); + const getSnapshot = useCallback( + () => workerPool?.getDiffResultCache(props.fileDiff) !== undefined, + [props.fileDiff, workerPool], + ); + const highlightCached = useSyncExternalStore(subscribe, getSnapshot, () => false); + const renderKey = props.fileDiff.cacheKey + ? `${props.fileDiff.cacheKey}:${highlightCached ? "highlighted" : "pending"}` + : undefined; + return ; +} diff --git a/apps/web/src/components/source-control/SourceControlPanelView.tsx b/apps/web/src/components/source-control/SourceControlPanelView.tsx new file mode 100644 index 000000000000..25e86076eba0 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelView.tsx @@ -0,0 +1,835 @@ +import { useAtomValue } from "@effect/atom-react"; +import { FileDiff, useWorkerPool } from "@pierre/diffs/react"; +import { + ChevronDown, + Copy, + GitBranch, + GitCommit, + GitPullRequestArrow, + Plus, + RefreshCw, + Trash2, +} from "lucide-react"; +import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { getRenderablePatch, resolveDiffThemeName } from "~/lib/diffRendering"; +import { cn } from "~/lib/utils"; +import { useEnvironmentQuery } from "~/state/query"; +import { + isSourceControlPanelCommandInterrupted, + resolveSourceControlPanelPresentationState, + useSourceControlPanelApi, +} from "~/state/sourceControlPanel"; +import { resolvePathLinkTarget } from "~/terminal-links"; + +import { Badge } from "../ui/badge"; +import { Button, InlineButton } from "../ui/button"; +import { ThreadPullRequestBadgeControl, prStatusIndicator } from "../ThreadStatusIndicators"; +import { useOpenPrLink } from "~/lib/openPullRequestLink"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxTrigger, +} from "../ui/combobox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Textarea } from "../ui/textarea"; +import { Tooltip, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; +import { + readCachedSourceControlPanelState, + sourceControlPanelStateCacheKey, + writeCachedSourceControlPanelState, + type SourceControlSectionKey, +} from "./SourceControlPanelCache"; +import { + COMMIT_PAGE_SIZE, + MIN_SECTION_WEIGHT, + SECTION_ORDER, + SECTION_TITLES, + applyWorkingTreeFileEnrichment, + commitUndoActionKey, + enrichmentFileKey, + expandedBranchesForSnapshot, + fileBasename, + isActionForced, + localOnlyBranches, + operationPathsForFile, + shouldEnrichWorkingTreeFile, + stashBranchName, + splitEnrichmentFileKey, + treeKey, + worktreeChangeSetId, + commitCountLabel, + type FileDiffLoadState, + type SectionKey, +} from "./SourceControlPanelModel"; +import { + AttentionIcon, + BranchSyncLabels, + CommitTooltip, + CompactBadge, + IconButton, + RemoteTooltip, + StashTooltip, + StatLabels, + WorkingTreeTooltip, + stashActivityTimestamp, +} from "./SourceControlPanelPrimitives"; +import { makeSourceControlPanelBranchRenderers } from "./SourceControlPanelBranches"; +import { BranchBadge, CollapsibleSection } from "./SourceControlPanelRows"; +import { LoaderCircle, Upload } from "lucide-react"; +import { TooltipPopup } from "../ui/tooltip"; +import { makeSourceControlPanelRepositoryRenderers } from "./SourceControlPanelRepositories"; +import { makeSourceControlPanelWorkingTreeRenderers } from "./SourceControlPanelWorkingTree"; +import type { + ReadySourceControlPanelController, + SourceControlPanelController, +} from "./useSourceControlPanelController"; + +export function SourceControlPanelView({ + controller, + repositoryAction, +}: { + readonly controller: SourceControlPanelController; + readonly repositoryAction?: ReactNode; +}) { + const openPrLink = useOpenPrLink( + controller.activeThreadRef ?? undefined, + controller.environmentId, + ); + const { + addRemoteOpen, + api, + branchDetailsByRef, + changedFiles, + chooseCompareBase, + collapsed, + commitDialogOpen, + commitMessageId, + compareBaseOverrides, + compareBaseRefs, + compareBaseDialogTarget, + compareBaseQuery, + copyText, + closeCreateBranchDialog, + createBranchCommitTarget, + createBranchName, + cwd, + dialogCommitMessage, + dialogStashMessage, + divergedSyncBranch, + error, + filteredCompareBaseRefs, + containerRef, + fetchActionableBranches, + gitAction, + isActionRunning, + loading, + presentationState, + publishBranch, + publishRemoteTarget, + refresh, + remoteName, + remoteUrl, + runAction, + runCreateBranchFromCommit, + runDivergedSync, + runGeneratedPanelStash, + runPanelCommit, + runPanelStash, + sectionWeights, + setAddRemoteOpen, + setCommitDialogOpen, + setCompareBaseDialogTarget, + setCompareBaseQuery, + setCreateBranchName, + setDialogCommitMessage, + setDialogStashMessage, + setDivergedSyncBranch, + setPublishRemoteTarget, + setRemoteName, + setRemoteUrl, + setStashDialogTarget, + selectedChangedFiles, + snapshot, + stashDialogTarget, + stashMessageId, + startSectionResize, + toggleSection, + } = controller; + const section = (key: SectionKey, children: ReactNode, action?: ReactNode) => ( + toggleSection(key)} + onResizeStart={startSectionResize} + action={action} + > + {children} + + ); + + if (presentationState.status === "loading") { + return ( +
+ {presentationState.message} +
+ ); + } + + if (presentationState.status === "unavailable") { + return ( +
+
+
+ {presentationState.message} +
+
+ copyText(error ?? "", "No error to copy.")} + > + + +
+
+ +
+ ); + } + + if (!snapshot) { + return null; + } + + const readyController: ReadySourceControlPanelController = { ...controller, snapshot }; + const branchRenderers = makeSourceControlPanelBranchRenderers(readyController); + const { branchRow } = branchRenderers; + const { localBranchesRow, remoteRow, stashRow } = makeSourceControlPanelRepositoryRenderers( + readyController, + branchRenderers, + ); + const { localBranchesWithoutUpstream, renderWorkingTreeRow, workItems } = + makeSourceControlPanelWorkingTreeRenderers(readyController); + const showRefreshSpinner = + loading && presentationState.status === "ready" && presentationState.syncMessage !== null; + const syncBannerMessage = showRefreshSpinner ? null : presentationState.syncMessage; + const repositorySummary = ( +
+
+
+ + {snapshot.status.refName ?? "Detached HEAD"} + + +
+ {showRefreshSpinner ? ( + + + + + } + /> + {presentationState.syncMessage} + + ) : null} + {repositoryAction ?
{repositoryAction}
: null} +
+
+ + {changedFiles.length > 0 + ? changedFiles.length === 1 + ? "1 file" + : `${changedFiles.length} files` + : "Clean"} + + + {snapshot.status.aheadOfDefaultCount ? ( + {snapshot.status.aheadOfDefaultCount} ahead of default + ) : null} +
+ {syncBannerMessage ? ( +
+
+ {syncBannerMessage} +
+
+ copyText(error ?? "", "No error to copy.")} + > + + +
+
+ ) : null} +
+ ); + + const workSection = ( +
+ {workItems.length === 0 ? ( +
+ Everything is up-to-date. +
+ ) : ( + workItems.map((item) => { + switch (item.kind) { + case "working-tree": + return
{renderWorkingTreeRow(item.changeSet)}
; + case "branch": + return branchRow(item.branch); + case "fork-branch": { + const pr = item.fork.pr; + const fetchKey = `fork-fetch:${item.fork.localBranchName}:${item.fork.remoteRefName}`; + const detailsKey = treeKey( + "fork-details", + `${item.fork.localBranchName}:${item.fork.remoteRefName}`, + ); + return branchRow(item.branch, { + key: treeKey( + "fork-branch", + `${item.fork.localBranchName}:${item.fork.remoteRefName}`, + ), + detailsKey, + compareBaseRef: item.fork.remoteRefName, + syncCounts: { + aheadCount: item.fork.aheadCount, + behindCount: item.fork.behindCount, + }, + attention: "behind", + syncLabel: "Fetch", + syncState: "fetch", + syncActionKey: fetchKey, + fetchActionKey: fetchKey, + secondaryBadge: vs {item.fork.remoteRefName}, + pullRequestBadge: pr ? ( + event.stopPropagation()}> + } + badge={null} + number={pr.number} + url={pr.url} + status={prStatusIndicator( + pr, + snapshot.remotes.find((remote) => remote.name === item.fork.remoteName) + ?.provider, + )} + onOpenList={() => {}} + onOpenPullRequest={(event) => openPrLink(event, pr.url)} + /> + + ) : null, + onSync: () => + void runAction( + fetchKey, + "Fetching", + () => + api?.vcs.fetchBranch({ + cwd, + branchName: item.fork.remoteRefName, + }) ?? Promise.resolve(), + ), + }); + } + case "stash": + return stashRow(item.stash); + } + }) + )} +
+ ); + + const remotesSection = ( +
+ {localBranchesWithoutUpstream.length > 0 + ? localBranchesRow(localBranchesWithoutUpstream) + : null} + {snapshot.remotes.length === 0 && localBranchesWithoutUpstream.length === 0 ? ( +
No remotes configured.
+ ) : ( + snapshot.remotes.map(remoteRow) + )} +
+ ); + + return ( + <> + +
+ {repositorySummary} +
+ {SECTION_ORDER.map((key) => { + switch (key) { + case "work": + return section( + key, + workSection, + void fetchActionableBranches(true)} + > + + , + ); + case "remotes": + return section( + key, + remotesSection, +
+ + void runAction("remotes-fetch-all", "Fetching", async () => { + await api?.vcs.fetchAllRemotes({ cwd, force: true }); + }) + } + > + + + setAddRemoteOpen(true)}> + + +
, + ); + } + })} +
+
+
+ { + if (!open) closeCreateBranchDialog(); + }} + > + + + Create branch + + Create a branch from {createBranchCommitTarget?.shortSha ?? "this commit"}. + + + + setCreateBranchName(event.currentTarget.value)} + /> + + + + + + + + + + + Add remote + Register a Git remote for this repository. + + + setRemoteName(event.currentTarget.value)} + /> + setRemoteUrl(event.currentTarget.value)} + /> + + + + + + + + { + if (!open) setPublishRemoteTarget(null); + }} + > + + + Publish branch + + Choose the remote to set as upstream for{" "} + {publishRemoteTarget?.branch.name ?? "this branch"}. + + + + {snapshot.remotes.map((remote) => ( + + ))} + + + + + + + { + if (open) return; + setCompareBaseDialogTarget(null); + setCompareBaseQuery(""); + }} + > + + + Choose compare base + + Select the ref to compare with {compareBaseDialogTarget?.branch.name ?? "this branch"} + + + + { + if (!open) setCompareBaseQuery(""); + }} + > + }> + + + {compareBaseDialogTarget + ? (branchDetailsByRef.get(compareBaseDialogTarget.detailsKey)?.baseRef ?? + compareBaseOverrides.get(compareBaseDialogTarget.detailsKey) ?? + "Choose ref") + : "Choose ref"} + + + + +
+ setCompareBaseQuery(event.currentTarget.value)} + /> +
+ No refs found. + + {filteredCompareBaseRefs.map((refName) => ( + chooseCompareBase(refName)} + > +
+ + {refName} +
+
+ ))} +
+
+
+
+ + + +
+
+ { + if (!open) setDivergedSyncBranch(null); + }} + > + + + Sync diverged branch + + Choose how to reconcile local and upstream commits for{" "} + {divergedSyncBranch?.name ?? "this branch"}. + + + + + + + + + + + + + + Commit selected changes + + Provide a message, or leave it blank to auto-generate one. + + + + +