From 10f6b88380011b466e0be0355b76874e41db43a2 Mon Sep 17 00:00:00 2001 From: Nicholas Zolton Date: Tue, 29 Sep 2026 22:44:57 -0700 Subject: [PATCH] feat(vcs): support Jujutsu with durable workspace recovery --- .github/actions/setup-jj/action.yml | 45 + .github/workflows/ci.yml | 4 + .../src/features/review/ReviewSheet.tsx | 23 +- .../src/features/review/reviewModel.test.ts | 24 + .../mobile/src/features/review/reviewModel.ts | 29 +- .../src/features/review/useReviewSections.ts | 11 +- .../SettingsServerControlsRouteScreen.tsx | 10 +- .../threads/NewTaskContextPickerScreens.tsx | 33 +- .../features/threads/NewTaskDraftScreen.tsx | 10 +- .../features/threads/ThreadDetailScreen.tsx | 21 +- .../features/threads/ThreadGitControls.tsx | 56 +- .../features/threads/git/GitBranchesSheet.tsx | 41 +- .../features/threads/git/GitCommitSheet.tsx | 11 +- .../features/threads/git/GitConfirmSheet.tsx | 6 +- .../features/threads/git/GitOverviewSheet.tsx | 97 +- .../threads/git/gitSheetComponents.tsx | 6 +- .../new-task-context-presentation.test.ts | 22 + .../threads/new-task-context-presentation.ts | 14 +- .../threads/new-task-flow-provider.tsx | 12 +- .../features/threads/worktree-setup-card.tsx | 49 +- .../threads/worktree-setup-sheet.android.tsx | 4 +- .../features/threads/worktree-setup-sheet.tsx | 5 +- .../state/use-selected-thread-git-actions.ts | 122 +- .../state/use-selected-thread-git-state.ts | 7 + apps/mobile/src/state/vcs.ts | 16 + .../OrchestrationEngineHarness.integration.ts | 214 ++- .../TestProviderAdapter.integration.ts | 5 + .../integration/jujutsu.integration.test.ts | 385 +++++ ...ProviderSessionStartup.integration.test.ts | 4 +- .../providerService.integration.test.ts | 4 + .../checkpointing/CheckpointDiffQuery.test.ts | 20 +- .../src/checkpointing/CheckpointStore.test.ts | 65 +- .../src/checkpointing/CheckpointStore.ts | 24 +- apps/server/src/git/ChangeRequestStep.test.ts | 137 ++ apps/server/src/git/GitManager.ts | 1307 ++++++++++------- .../server/src/git/GitWorkflowService.test.ts | 218 ++- apps/server/src/git/GitWorkflowService.ts | 754 ++++++---- apps/server/src/jj/JjFailure.ts | 15 + .../server/src/jj/JjPullRequestThread.test.ts | 205 +++ apps/server/src/jj/JjPullRequestThread.ts | 221 +++ apps/server/src/jj/JjRefs.test.ts | 251 ++++ apps/server/src/jj/JjRefs.ts | 396 +++++ apps/server/src/jj/JjRemotes.test.ts | 193 +++ apps/server/src/jj/JjRemotes.ts | 526 +++++++ apps/server/src/jj/JjStackedAction.test.ts | 417 ++++++ apps/server/src/jj/JjStackedAction.ts | 491 +++++++ apps/server/src/jj/JjStatus.test.ts | 374 +++++ apps/server/src/jj/JjStatus.ts | 441 ++++++ apps/server/src/jj/JjWorkflow.ts | 507 +++++++ apps/server/src/jj/JjWorkspaceNaming.ts | 24 + apps/server/src/jj/JjWorkspaces.test.ts | 512 +++++++ apps/server/src/jj/JjWorkspaces.ts | 565 +++++++ .../orchestration/Layers/CheckpointReactor.ts | 46 +- .../Layers/ProviderCommandReactor.ts | 27 +- .../Layers/ProviderRuntimeIngestion.test.ts | 6 +- .../Layers/ProviderRuntimeIngestion.ts | 2 +- .../ThreadPullRequestReactor.test.ts | 3 +- .../orchestration/ThreadPullRequestReactor.ts | 7 +- .../ThreadSettlementReactor.test.ts | 101 +- .../orchestration/ThreadSettlementReactor.ts | 4 +- .../src/project/AgentSessionImporter.test.ts | 4 + .../src/project/AgentSessionScanner.test.ts | 42 + .../server/src/project/AgentSessionScanner.ts | 24 +- .../RepositoryIdentityResolver.test.ts | 53 + .../src/project/RepositoryIdentityResolver.ts | 70 +- apps/server/src/project/WorkspaceSettings.ts | 31 + .../provider/CodexDeveloperInstructions.ts | 19 +- .../Layers/AntigravityAdapter.test.ts | 4 +- .../src/provider/Layers/AntigravityAdapter.ts | 6 +- .../src/provider/Layers/ClaudeAdapter.ts | 4 +- .../src/provider/Layers/CodexAdapter.ts | 2 + .../CodexCollabRuntime.integration.test.ts | 145 +- .../provider/Layers/CodexSessionRuntime.ts | 18 +- .../src/provider/Layers/CursorAdapter.ts | 6 +- .../server/src/provider/Layers/GrokAdapter.ts | 1 + .../src/provider/Layers/OpenCodeAdapter.ts | 1 + .../provider/Layers/ProviderService.test.ts | 5 + .../src/provider/Layers/ProviderService.ts | 27 +- .../src/provider/RuntimeInstructions.ts | 7 +- .../src/provider/Services/ProviderAdapter.ts | 5 +- apps/server/src/review/ReviewService.test.ts | 132 +- apps/server/src/review/ReviewService.ts | 18 +- apps/server/src/server.test.ts | 26 + apps/server/src/server.ts | 23 +- apps/server/src/serverRuntimeStartup.test.ts | 12 +- apps/server/src/serverRuntimeStartup.ts | 10 +- .../SourceControlDiscovery.test.ts | 2 +- .../sourceControl/SourceControlDiscovery.ts | 2 +- .../SourceControlRepositoryService.test.ts | 57 + .../SourceControlRepositoryService.ts | 29 +- apps/server/src/storageCleanup.ts | 117 +- apps/server/src/vcs/GitVcsDriver.test.ts | 1 + apps/server/src/vcs/GitVcsDriver.ts | 37 +- apps/server/src/vcs/GitVcsDriverCore.ts | 8 +- apps/server/src/vcs/JjAvailability.test.ts | 149 ++ apps/server/src/vcs/JjAvailability.ts | 124 ++ apps/server/src/vcs/JjCheckpoints.test.ts | 533 +++++++ apps/server/src/vcs/JjCheckpoints.ts | 350 +++++ apps/server/src/vcs/JjProcess.test.ts | 222 +++ apps/server/src/vcs/JjProcess.ts | 183 +++ apps/server/src/vcs/JjRepo.test.ts | 216 +++ apps/server/src/vcs/JjRepo.ts | 128 ++ apps/server/src/vcs/JjReviewDiff.test.ts | 373 +++++ apps/server/src/vcs/JjReviewDiff.ts | 409 ++++++ apps/server/src/vcs/JjRevset.test.ts | 42 + apps/server/src/vcs/JjRevset.ts | 61 + apps/server/src/vcs/JjVcsDriver.test.ts | 436 ++++++ apps/server/src/vcs/JjVcsDriver.ts | 854 +++++++++++ apps/server/src/vcs/VcsDriver.ts | 24 +- apps/server/src/vcs/VcsDriverRegistry.test.ts | 40 + apps/server/src/vcs/VcsDriverRegistry.ts | 15 + apps/server/src/vcs/VcsPathCodecs.ts | 45 + apps/server/src/vcs/VcsProcess.test.ts | 97 +- apps/server/src/vcs/VcsProcess.ts | 11 +- .../src/vcs/VcsProvisioningService.test.ts | 12 +- apps/server/src/vcs/VcsProvisioningService.ts | 3 +- apps/server/src/vcs/WorkspaceFileSafety.ts | 63 + apps/server/src/vcs/WorkspaceOwnership.ts | 104 ++ apps/server/src/vcs/testing/JjTestSupport.ts | 199 +++ .../vcs/testing/VcsDriverContractHarness.ts | 5 +- apps/server/src/ws.ts | 4 + .../components/BranchToolbar.logic.test.ts | 52 +- .../web/src/components/BranchToolbar.logic.ts | 39 +- apps/web/src/components/BranchToolbar.tsx | 26 +- .../BranchToolbarBranchSelector.tsx | 75 +- .../BranchToolbarEnvModeSelector.tsx | 23 +- apps/web/src/components/ChatView.tsx | 10 +- apps/web/src/components/DiffPanel.tsx | 23 +- .../GitActionsControl.logic.test.ts | 42 +- .../src/components/GitActionsControl.logic.ts | 44 +- apps/web/src/components/GitActionsControl.tsx | 191 ++- .../components/PullRequestThreadDialog.tsx | 6 +- apps/web/src/components/chat/ChatComposer.tsx | 7 + .../chat/ComposerPrimaryActions.tsx | 6 +- .../pullRequest/PullRequestDetailPanel.tsx | 5 +- .../settings/ProjectActionsSettings.tsx | 3 +- .../settings/ProjectDefaultsSettings.tsx | 32 +- .../settings/SettingInheritance.tsx | 4 +- .../settings/SourceControlSettings.tsx | 16 +- .../src/components/settings/settingsSearch.ts | 19 +- .../src/components/threadActionMenu.logic.ts | 10 +- apps/web/src/hooks/useThreadActions.ts | 24 +- apps/web/src/state/sourceControlActions.ts | 40 +- apps/web/src/state/vcs.ts | 16 + docs/README.md | 1 + docs/internals/glossary.md | 11 + docs/internals/jujutsu.md | 98 ++ docs/user/source-control.md | 47 + .../src/state/gitActions.test.ts | 172 +++ .../client-runtime/src/state/gitActions.ts | 271 ++-- packages/contracts/src/git.ts | 14 +- packages/contracts/src/provider.ts | 2 + packages/contracts/src/settings.ts | 4 + packages/shared/package.json | 4 + packages/shared/src/git.test.ts | 53 +- packages/shared/src/git.ts | 9 +- packages/shared/src/vcs.ts | 70 + 157 files changed, 14550 insertions(+), 1718 deletions(-) create mode 100644 .github/actions/setup-jj/action.yml create mode 100644 apps/server/integration/jujutsu.integration.test.ts create mode 100644 apps/server/src/git/ChangeRequestStep.test.ts create mode 100644 apps/server/src/jj/JjFailure.ts create mode 100644 apps/server/src/jj/JjPullRequestThread.test.ts create mode 100644 apps/server/src/jj/JjPullRequestThread.ts create mode 100644 apps/server/src/jj/JjRefs.test.ts create mode 100644 apps/server/src/jj/JjRefs.ts create mode 100644 apps/server/src/jj/JjRemotes.test.ts create mode 100644 apps/server/src/jj/JjRemotes.ts create mode 100644 apps/server/src/jj/JjStackedAction.test.ts create mode 100644 apps/server/src/jj/JjStackedAction.ts create mode 100644 apps/server/src/jj/JjStatus.test.ts create mode 100644 apps/server/src/jj/JjStatus.ts create mode 100644 apps/server/src/jj/JjWorkflow.ts create mode 100644 apps/server/src/jj/JjWorkspaceNaming.ts create mode 100644 apps/server/src/jj/JjWorkspaces.test.ts create mode 100644 apps/server/src/jj/JjWorkspaces.ts create mode 100644 apps/server/src/project/WorkspaceSettings.ts create mode 100644 apps/server/src/vcs/JjAvailability.test.ts create mode 100644 apps/server/src/vcs/JjAvailability.ts create mode 100644 apps/server/src/vcs/JjCheckpoints.test.ts create mode 100644 apps/server/src/vcs/JjCheckpoints.ts create mode 100644 apps/server/src/vcs/JjProcess.test.ts create mode 100644 apps/server/src/vcs/JjProcess.ts create mode 100644 apps/server/src/vcs/JjRepo.test.ts create mode 100644 apps/server/src/vcs/JjRepo.ts create mode 100644 apps/server/src/vcs/JjReviewDiff.test.ts create mode 100644 apps/server/src/vcs/JjReviewDiff.ts create mode 100644 apps/server/src/vcs/JjRevset.test.ts create mode 100644 apps/server/src/vcs/JjRevset.ts create mode 100644 apps/server/src/vcs/JjVcsDriver.test.ts create mode 100644 apps/server/src/vcs/JjVcsDriver.ts create mode 100644 apps/server/src/vcs/VcsPathCodecs.ts create mode 100644 apps/server/src/vcs/WorkspaceFileSafety.ts create mode 100644 apps/server/src/vcs/WorkspaceOwnership.ts create mode 100644 apps/server/src/vcs/testing/JjTestSupport.ts create mode 100644 docs/internals/jujutsu.md create mode 100644 packages/client-runtime/src/state/gitActions.test.ts create mode 100644 packages/shared/src/vcs.ts diff --git a/.github/actions/setup-jj/action.yml b/.github/actions/setup-jj/action.yml new file mode 100644 index 000000000000..2b50434c2047 --- /dev/null +++ b/.github/actions/setup-jj/action.yml @@ -0,0 +1,45 @@ +name: Setup Jujutsu +description: Install the jj CLI from the official jj-vcs release artifacts. + +inputs: + version: + description: Jujutsu release tag to install. + required: false + default: v0.42.0 + +runs: + using: composite + steps: + - name: Install jj + shell: bash + env: + JJ_VERSION: ${{ inputs.version }} + run: | # zizmor: ignore[github-env] -- runner-owned path and validated version + set -euo pipefail + + if [[ ! "$JJ_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid jj release tag" >&2 + exit 1 + fi + + case "${RUNNER_OS}-${RUNNER_ARCH}" in + Linux-X64) + asset="jj-${JJ_VERSION}-x86_64-unknown-linux-musl.tar.gz" + ;; + *) + echo "Unsupported runner for jj install: ${RUNNER_OS}-${RUNNER_ARCH}" >&2 + exit 1 + ;; + esac + + install_dir="${RUNNER_TEMP}/jj-${JJ_VERSION}" + mkdir -p "$install_dir" + + curl -fsSL \ + "https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/${asset}" \ + -o "${RUNNER_TEMP}/${asset}" + tar -xzf "${RUNNER_TEMP}/${asset}" -C "$install_dir" + + chmod +x "${install_dir}/jj" + echo "$install_dir" >> "$GITHUB_PATH" + "${install_dir}/jj" --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 611d4cf44f75..7b0db0c8c9ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,10 @@ jobs: cache: true run-install: true + # The jj suites in apps/server/src/vcs skip themselves when jj is absent, + # so this step is what makes them actually run. + - uses: ./.github/actions/setup-jj + # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` # job, which covers @t3tools/desktop, needs the download. diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 08888dd469d2..15fa2c1ac083 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -1,3 +1,6 @@ +import { getVcsTerminology, type VcsTerminology } from "@t3tools/shared/vcs"; +import { useEnvironmentQuery } from "../../state/query"; +import { vcsEnvironment } from "../../state/vcs"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; @@ -71,6 +74,7 @@ import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal"; function ReviewHeader( props: Parameters[0] & { readonly iconColor: string; + readonly vcsTerminology: VcsTerminology; readonly sectionMenu: ReturnType; readonly showSectionToolbar: boolean; readonly showChangedFilesToggle: boolean; @@ -126,8 +130,14 @@ function ReviewHeader( id: "sections", inline: true, items: [ - sectionAction(props.sectionMenu.workingTree, "Working tree"), - sectionAction(props.sectionMenu.branchChanges, "Branch changes"), + sectionAction( + props.sectionMenu.workingTree, + props.vcsTerminology.workingTreeNounTitle, + ), + sectionAction( + props.sectionMenu.branchChanges, + `${props.vcsTerminology.refNounTitle} changes`, + ), sectionAction(props.sectionMenu.latestTurn, "Latest turn"), ], }, @@ -461,6 +471,13 @@ export function ReviewSheet(props: ReviewSheetProps) { const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); const { selectedThreadCwd } = useSelectedThreadWorktree(); + const gitStatusQuery = useEnvironmentQuery( + selectedThreadCwd === null + ? null + : vcsEnvironment.status({ environmentId, input: { cwd: selectedThreadCwd } }), + ); + const vcsKind = gitStatusQuery.data?.vcs?.kind ?? null; + const vcsTerminology = getVcsTerminology(vcsKind); // With a solid (non-overlay) header the content lays out below the header // natively, so no manual top inset is needed. (Android renders its own // in-flow AndroidScreenHeader, so it needs no inset either.) @@ -482,6 +499,7 @@ export function ReviewSheet(props: ReviewSheetProps) { environmentId, threadId, reviewCache, + vcsKind, }); useReviewDiffPrewarming({ threadKey: reviewCache.threadKey, @@ -724,6 +742,7 @@ export function ReviewSheet(props: ReviewSheetProps) { subtitle={headerSubtitle} androidSubtitle={androidHeaderSubtitle} iconColor={headerIcon} + vcsTerminology={vcsTerminology} selectedThreadCwd={selectedThreadCwd} sectionMenu={sectionMenu} selectedSection={selectedSection} diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index aadddfc9f001..2d1aec67e09f 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -133,6 +133,30 @@ describe("buildReviewSectionItems", () => { ]); expect(getDefaultReviewSectionId(items)).toBe("git:working-tree"); }); + + it("names a missing base with the project's ref noun", () => { + const [section] = buildReviewSectionItems({ + checkpoints: [], + gitSections: [ + { + id: "branch-range", + kind: "branch-range", + title: "Bookmark changes", + baseRef: null, + headRef: null, + diff: "", + diffHash: "h", + truncated: false, + }, + ], + turnDiffById: {}, + loadingTurnIds: {}, + loadingGitSections: false, + vcsKind: "jj", + }); + + expect(section?.subtitle).toBe("Base bookmark unavailable"); + }); }); describe("buildReviewParsedDiff", () => { diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 06fe4c9597d9..8a585a1b3368 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -1,6 +1,11 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; -import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts"; +import type { + OrchestrationCheckpointSummary, + ReviewDiffPreviewSource, + VcsDriverKind, +} from "@t3tools/contracts"; +import { getVcsTerminology, type VcsTerminology } from "@t3tools/shared/vcs"; import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -9,8 +14,6 @@ import * as Order from "effect/Order"; export type ReviewSectionKind = "turn" | "working-tree" | "branch-range"; const DIRTY_WORKTREE_SECTION_ID = "git:working-tree"; -const DIRTY_WORKTREE_TITLE = "Dirty worktree"; -const DIRTY_WORKTREE_SUBTITLE = "Tracked, staged, and untracked worktree changes"; export interface ReviewSectionItem { readonly id: string; @@ -121,14 +124,18 @@ const readyCheckpointOrder = Order.make( compareCheckpointTurnCountDescending, ); -function gitSubtitle(section: ReviewDiffPreviewSource): string | null { +function workingTreeSubtitle(terminology: VcsTerminology): string { + return `Tracked, staged, and untracked ${terminology.workspaceNoun} changes`; +} + +function gitSubtitle(section: ReviewDiffPreviewSource, terminology: VcsTerminology): string | null { if (section.kind === "working-tree") { - return DIRTY_WORKTREE_SUBTITLE; + return workingTreeSubtitle(terminology); } if (section.baseRef) { return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`; } - return "Base branch unavailable"; + return `Base ${terminology.refNoun} unavailable`; } function stripTrailingNewline(value: string): string { @@ -416,7 +423,9 @@ export function buildReviewSectionItems(input: { readonly turnDiffById: Readonly>; readonly loadingTurnIds: Readonly>; readonly loadingGitSections: boolean; + readonly vcsKind?: VcsDriverKind | null; }): ReadonlyArray { + const terminology = getVcsTerminology(input.vcsKind); const turnItems = getReadyReviewCheckpoints(input.checkpoints).map( (checkpoint) => { const id = getReviewSectionIdForCheckpoint(checkpoint); @@ -435,7 +444,7 @@ export function buildReviewSectionItems(input: { id: `git:${section.kind}`, kind: section.kind, title: section.title, - subtitle: gitSubtitle(section), + subtitle: gitSubtitle(section, terminology), diff: section.diff, source: section, ...(section.files ? { files: section.files } : {}), @@ -449,8 +458,10 @@ export function buildReviewSectionItems(input: { { id: DIRTY_WORKTREE_SECTION_ID, kind: "working-tree", - title: DIRTY_WORKTREE_TITLE, - subtitle: DIRTY_WORKTREE_SUBTITLE, + // Mirrors the title each driver sends for this section, so the + // placeholder does not flicker into a different name on arrival. + title: input.vcsKind === "jj" ? "Working copy" : "Dirty worktree", + subtitle: workingTreeSubtitle(terminology), diff: null, isLoading: true, } satisfies ReviewSectionItem, diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index f233ccbf8eb2..b3c0f3537578 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -1,7 +1,12 @@ import { useCallback, useEffect, useMemo } from "react"; import * as DateTime from "effect/DateTime"; -import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts"; +import type { + EnvironmentId, + OrchestrationCheckpointSummary, + ThreadId, + VcsDriverKind, +} from "@t3tools/contracts"; import { useCheckpointDiff } from "../../state/queries"; import { useEnvironmentQuery } from "../../state/query"; @@ -28,8 +33,10 @@ export function useReviewSections(input: { readonly environmentId?: EnvironmentId; readonly threadId?: ThreadId; readonly reviewCache: ReviewCacheForThread; + readonly vcsKind?: VcsDriverKind | null; }) { const { environmentId, reviewCache, threadId } = input; + const vcsKind = input.vcsKind ?? null; const enabled = input.enabled ?? true; const selectedThread = useSelectedThreadDetail(); const { selectedThreadCwd } = useSelectedThreadWorktree(); @@ -71,6 +78,7 @@ export function useReviewSections(input: { turnDiffById: reviewCache.turnDiffById, loadingTurnIds, loadingGitSections: diffPreview.isPending, + vcsKind, }), [ diffPreview.isPending, @@ -79,6 +87,7 @@ export function useReviewSections(input: { readyCheckpoints, reviewCache.gitSections, reviewCache.turnDiffById, + vcsKind, ], ); const selectedSection = useMemo( diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index a8960e813a46..10b23af16b2d 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -53,7 +53,7 @@ const PAGE_TITLES: Record = { const PAGE_PROJECT_KEYS: Record = { "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"], - "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"], + "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin", "enableVcsAgentHints"], "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"], maintenance: ["continueThreadsAfterServerUpdate"], }; @@ -387,6 +387,14 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { disabled={disabledFor("newWorktreesStartFromOrigin")} onValueChange={(value) => write({ newWorktreesStartFromOrigin: value })} /> + write({ enableVcsAgentHints: value })} + /> (null); const allowSelectionNavigationRef = useRef(false); const mountedRef = useRef(true); - const screenTitle = flow.workspaceMode === "worktree" ? "Base branch" : "Branch"; + const screenTitle = + flow.workspaceMode === "worktree" + ? `Base ${flow.vcsTerminology.refNoun}` + : flow.vcsTerminology.refNounTitle; const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; const selectedBranchName = flow.selectedBranchName ?? @@ -327,8 +330,10 @@ export function NewTaskBranchPickerRouteScreen() { if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); Alert.alert( - "Could not switch branch", - error instanceof Error ? error.message : "The branch could not be checked out.", + `Could not switch ${flow.vcsTerminology.refNoun}`, + error instanceof Error + ? error.message + : `The ${flow.vcsTerminology.refNoun} could not be checked out.`, ); } return; @@ -357,6 +362,7 @@ export function NewTaskBranchPickerRouteScreen() { flow.selectedProject, flow.setBranchQuery, flow.workspaceMode, + flow.vcsTerminology, navigation, switchRef, ], @@ -365,7 +371,11 @@ export function NewTaskBranchPickerRouteScreen() { const renderBranch = useCallback( ({ item, index }: { readonly item: VcsRef; readonly index: number }) => ( : null} {flow.branchesLoading - ? "Loading branches…" + ? `Loading ${flow.vcsTerminology.refNounPlural}…` : flow.branchesError ? flow.branchesError : flow.branchQuery - ? "No matching branches" - : "No branches available"} + ? `No matching ${flow.vcsTerminology.refNounPlural}` + : `No ${flow.vcsTerminology.refNounPlural} available`} {!flow.branchesLoading && flow.branchesError ? ( @@ -506,7 +517,7 @@ export function NewTaskBranchPickerRouteScreen() { ? () => [ createNativeMailSearchToolbarItem({ onSearchTextChange: flow.setBranchQuery, - placeholder: "Find a branch", + placeholder: `Find a ${flow.vcsTerminology.refNoun}`, searchTextChangeId: "new-task-branch-search-text", showsSearchDismissButton: true, }), @@ -519,7 +530,7 @@ export function NewTaskBranchPickerRouteScreen() { autoCapitalize: "none", hideNavigationBar: false, obscureBackground: false, - placeholder: "Find a branch", + placeholder: `Find a ${flow.vcsTerminology.refNoun}`, onChangeText: (event) => { flow.setBranchQuery(event.nativeEvent.text); }, diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 7e90b0743374..025298090ab4 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -991,10 +991,12 @@ export function NewTaskDraftScreen(props: { branchName: selectedBranchName, startFromOrigin: flow.startFromOrigin, workspaceMode: flow.workspaceMode, + terminology: flow.vcsTerminology, }); const workspaceLabel = resolveNewTaskWorkspaceLabel({ workspaceMode: flow.workspaceMode, worktreePath: flow.selectedWorktreePath, + terminology: flow.vcsTerminology, }); const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0; @@ -1524,7 +1526,7 @@ export function NewTaskDraftScreen(props: { const workspaceControls = ( ( @@ -1541,11 +1543,13 @@ export function NewTaskDraftScreen(props: { /> openContextPicker("NewTaskBranch")} /> diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e32240748861..64581570babf 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -76,6 +76,10 @@ import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; import { deviceEnvironment } from "../../state/device"; import { useEnvironmentQuery } from "../../state/query"; +import { useThreadSelection } from "../../state/use-thread-selection"; +import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { vcsEnvironment } from "../../state/vcs"; +import { resolveVcsTerminology } from "@t3tools/shared/vcs"; import { threadDevicePreviews } from "../devices/threadDevicePreviews"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; @@ -281,6 +285,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }); }, [navigation, props.environmentId, props.selectedThread.id]); const insets = useSafeAreaInsets(); + // Same (environment, cwd) key the git sheets use, so this reads the shared + // status atom rather than opening a second stream. + const { selectedThread } = useThreadSelection(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + const vcsStatus = useEnvironmentQuery( + selectedThread === null || selectedThreadCwd === null + ? null + : vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }), + ); + const vcsTerminology = resolveVcsTerminology(vcsStatus.data); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const liveKeyboardHeight = useKeyboardState((state) => state.height); // Android can swallow the IME hide callbacks when the app is backgrounded @@ -392,7 +409,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (props.worktreeSetup) return null; return { kind: "preparing", - label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…", + label: props.creationState.preparingWorktree + ? `Setting up ${vcsTerminology.workspaceNoun}…` + : "Starting…", }; } if (props.creationState?.kind === "failed") { diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index e3a4033cd809..4fca0564f7aa 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -12,11 +12,10 @@ import { requiresDefaultBranchConfirmation, resolveQuickAction, } from "@t3tools/client-runtime/state/vcs"; +import { resolveVcsTerminology, resolveVcsUnsupportedReason } from "@t3tools/shared/vcs"; import { useNavigation } from "@react-navigation/native"; import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useCallback, useMemo } from "react"; -import { Alert } from "react-native"; -import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { basename, getTerminalStatusLabel, @@ -114,7 +113,10 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { const threadId = props.threadId; const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; - const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD"; + const vcsTerminology = resolveVcsTerminology(gitStatus); + const vcsUnsupportedReason = resolveVcsUnsupportedReason(gitStatus); + const currentBranchLabel = + gitStatus?.refName ?? props.currentBranch ?? `No ${vcsTerminology.refNoun}`; const busy = gitOperationLabel !== null; const isRepo = gitStatus?.isRepo ?? true; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; @@ -125,21 +127,20 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { isRepo ? resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote) : { - label: "Git unavailable", + label: `${vcsTerminology.systemName} unavailable`, disabled: true, kind: "show_hint" as const, - hint: "This workspace is not a git repository.", + hint: `This workspace is not a ${vcsTerminology.systemName} repository.`, }, - [busy, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo], + [busy, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo, vcsTerminology], ); - const quickActionHint = quickAction.disabled - ? (quickAction.hint ?? "This action is unavailable.") - : null; + const quickActionHint = + vcsUnsupportedReason ?? + (quickAction.disabled ? (quickAction.hint ?? "This action is unavailable.") : null); const quickActionIcon: QuickActionIcon = (() => { if (quickAction.kind === "run_pull") return "arrow.down.circle"; - if (quickAction.kind === "open_pr") return "arrow.up.right.circle"; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return "checkmark.circle"; if (quickAction.action === "push" || quickAction.action === "commit_push") @@ -148,17 +149,6 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { return "arrow.up.right.circle"; })(); - const openExistingPr = useCallback(async () => { - const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null; - if (!prUrl) { - Alert.alert("No open PR", "This branch does not have an open pull request."); - return; - } - if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) { - Alert.alert("Unable to open PR", "The pull request could not be opened."); - } - }, [gitStatus]); - const runActionWithPrompt = useCallback( async (input: GitActionRequestInput) => { const confirmableAction = @@ -193,10 +183,6 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { ); const runQuickAction = useCallback(async () => { - if (quickAction.kind === "open_pr") { - await openExistingPr(); - return; - } if (quickAction.kind === "run_pull") { await onPull(); return; @@ -204,7 +190,7 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { if (quickAction.kind === "run_action" && quickAction.action) { await runActionWithPrompt({ action: quickAction.action }); } - }, [onPull, openExistingPr, quickAction, runActionWithPrompt]); + }, [onPull, quickAction, runActionWithPrompt]); const openFiles = useCallback(() => { if (props.onOpenFilesInspector) { @@ -238,6 +224,7 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { return { currentBranchLabel, isRepo, + vcsTerminology, openFiles, openGitInspector, openReview, @@ -321,10 +308,10 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit variant: "plain", }, git: { - accessibilityLabel: "Git actions", + accessibilityLabel: `${model.vcsTerminology.systemName} actions`, icon: { name: "point.topleft.down.curvedto.point.bottomright.up", type: "sfSymbol" }, identifier: "thread-right-git", - label: "Git", + label: model.vcsTerminology.systemName, menu: { items: [ { @@ -347,7 +334,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit type: "action", }, { - description: "Turn diffs and worktree changes", + description: `Turn diffs and ${model.vcsTerminology.workspaceNoun} changes`, disabled: !model.isRepo, icon: { name: "text.bubble", type: "sfSymbol" }, label: "Review changes", @@ -355,14 +342,14 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit type: "action", }, { - description: "Commit, files, branches", + description: `Commit, files, ${model.vcsTerminology.refNounPlural}`, icon: { name: "ellipsis", type: "sfSymbol" }, label: "More", onPress: model.openGitInspector, type: "action", }, ], - title: "Git", + title: model.vcsTerminology.systemName, }, sharesBackground: true, type: "menu", @@ -372,6 +359,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit [ model.currentBranchLabel, model.isRepo, + model.vcsTerminology, model.openFiles, model.openGitInspector, model.openReview, @@ -517,7 +505,7 @@ function threadGitMenuDefinition( model: ReturnType, ): ScreenHeaderMenu { return { - title: "Git controls", + title: `${model.vcsTerminology.systemName} controls`, icon: "point.topleft.down.curvedto.point.bottomright.up", separateBackground: false, items: [ @@ -544,14 +532,14 @@ function threadGitMenuDefinition( title: "Review changes", icon: "text.bubble", disabled: !model.isRepo, - subtitle: "Turn diffs and worktree changes", + subtitle: `Turn diffs and ${model.vcsTerminology.workspaceNoun} changes`, onPress: model.openReview, }, { id: "git-more", title: "More", icon: "ellipsis", - subtitle: "Commit, files, branches", + subtitle: `Commit, files, ${model.vcsTerminology.refNounPlural}`, onPress: model.openGitInspector, }, ], diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index d16b72d13d81..468ee7152d3b 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -1,4 +1,5 @@ import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { resolveVcsTerminology } from "@t3tools/shared/vcs"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useState } from "react"; import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; @@ -40,7 +41,9 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { : null, ); - const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; + const vcsTerminology = resolveVcsTerminology(gitStatus.data); + const noRefLabel = `No ${vcsTerminology.refNoun}`; + const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? noRefLabel; const currentWorktreePath = selectedThreadWorktreePath; const availableBranches = gitState.selectedThreadBranches; const branchesLoading = gitState.selectedThreadBranchesLoading; @@ -48,7 +51,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const [newBranchName, setNewBranchName] = useState(""); const [worktreeBaseBranch, setWorktreeBaseBranch] = useState( - currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel, + currentBranchLabel === noRefLabel ? "main" : currentBranchLabel, ); const [worktreeBranchName, setWorktreeBranchName] = useState(""); @@ -76,7 +79,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ) : null} {Platform.OS === "android" ? ( navigation.goBack()} hideBottomBorder /> @@ -126,7 +129,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { New worktree {Platform.OS === "android" ? ( - Base branch + {`Base ${vcsTerminology.refNoun}`} ) : null} {Platform.OS === "android" ? ( - New branch + {`New ${vcsTerminology.refNoun}`} ) : null} + {currentWorktreePath ? ( + + {currentWorktreePath} + { + void gitActions.onRemoveSelectedThreadWorkspace().then((removed) => { + if (removed) navigation.goBack(); + }); + }} + /> + + ) : null} + Existing branches @@ -187,10 +210,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const subtitle = branch.worktreePath ? branch.worktreePath === currentWorktreePath ? "Checked out in this thread" - : "Checked out in another worktree" + : `Checked out in another ${vcsTerminology.workspaceNoun}` : branch.isDefault - ? "Default branch" - : "Local branch"; + ? `Default ${vcsTerminology.refNoun}` + : `Local ${vcsTerminology.refNoun}`; return ( + {gitStatus.data?.vcs?.kind === "jj" ? ( + + Jujutsu does not run Git hooks. + + ) : null} {isDefaultRef ? ( - Warning: this is the default branch. + {`Warning: this is the default ${vcsTerminology.refNoun}.`} ) : null} @@ -273,7 +280,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { void runCommitAction(true)} /> diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index 06cce85791f6..9543e8a2d777 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -31,6 +31,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { const insets = useSafeAreaInsets(); const { height: windowHeight } = useWindowDimensions(); const gitState = useSelectedThreadGitState(); + const vcsTerminology = gitState.vcsTerminology; const gitActions = useSelectedThreadGitActions(); const params = props.route.params; @@ -53,9 +54,10 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { action: confirmAction, branchName, includesCommit, + vcsTerminology, }) : null, - [branchName, confirmAction, includesCommit], + [branchName, confirmAction, includesCommit, vcsTerminology], ); const continuePendingAction = useCallback(async () => { @@ -168,7 +170,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { /> void movePendingActionToFeatureBranch()} /> diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 831ef1145cec..644b23fa0c31 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -4,11 +4,16 @@ import { getGitActionDisabledReason, requiresDefaultBranchConfirmation, } from "@t3tools/client-runtime/state/vcs"; +import { resolveVcsTerminology } from "@t3tools/shared/vcs"; import { resolveThreadPullRequestChains, threadPullRequestKeyOf, } from "@t3tools/shared/threadPullRequests"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { CommonActions, StackActions, @@ -41,6 +46,7 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread- import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; import { vcsEnvironment } from "../../../state/vcs"; +import { useAtomCommand } from "../../../state/use-atom-command"; import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation"; import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; @@ -75,6 +81,8 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); + const initRepository = useAtomCommand(vcsEnvironment.init, { reportFailure: false }); + const [isConverting, setIsConverting] = useState(false); const theme = useUniwindTheme(); const foregroundColor = theme["--color-foreground"]; const sheetColor = theme["--color-sheet"]; @@ -88,8 +96,10 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { : null, ); - const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; - const currentStatusSummary = statusSummary(gitStatus.data); + const vcsTerminology = resolveVcsTerminology(gitStatus.data); + const currentBranchLabel = + gitStatus.data?.refName ?? selectedThread?.branch ?? `No ${vcsTerminology.refNoun}`; + const currentStatusSummary = statusSummary(gitStatus.data, vcsTerminology); const currentWorktreePath = selectedThreadWorktreePath; const gitOperationLabel = gitState.gitOperationLabel; const busy = gitOperationLabel !== null; @@ -110,7 +120,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { item, gitStatus: gitStatus.data, isBusy: busy, - hasOriginRemote: hasPrimaryRemote, + hasPrimaryRemote, }), })), [busy, gitStatus.data, hasPrimaryRemote, menuItems], @@ -120,17 +130,6 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { void gitActions.refreshSelectedThreadGitStatus({ quiet: true }); }, [gitActions]); - const openExistingPr = useCallback(async () => { - const prUrl = gitStatus.data?.pr?.state === "open" ? gitStatus.data.pr.url : null; - if (!prUrl) { - Alert.alert("No open PR", "This branch does not have an open pull request."); - return; - } - if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) { - Alert.alert("Unable to open PR", "The pull request could not be opened."); - } - }, [gitStatus.data]); - const runActionWithPrompt = useCallback( async (input: GitActionRequestInput) => { const confirmableAction = @@ -170,10 +169,6 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const onPressMenuItem = useCallback( async (item: (typeof menuItems)[number]) => { if (item.disabled) return; - if (item.kind === "open_pr") { - await openExistingPr(); - return; - } if (item.dialogAction === "commit") { navigation.navigate("GitCommit", { environmentId: String(environmentId), @@ -189,7 +184,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { await runActionWithPrompt({ action: "create_pr" }); } }, - [environmentId, openExistingPr, navigation, runActionWithPrompt, threadId], + [environmentId, navigation, runActionWithPrompt, threadId], ); // Status facts live on the relevant rows instead of crowding the header @@ -208,15 +203,47 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const ahead = status.aheadCount ?? 0; return `${ahead} commit${ahead === 1 ? "" : "s"} ahead`; } - if (item.kind === "open_pr" && status.pr?.number != null) { - return `PR #${status.pr.number} ${status.pr.state ?? "open"}`; - } return undefined; }, [gitStatus.data, menuItems], ); const behindCount = gitStatus.data?.behindCount ?? 0; + const canConvertToJj = + gitStatus.data?.isRepo === true && + (gitStatus.data.vcs?.kind === undefined || gitStatus.data.vcs.kind === "git") && + selectedThreadWorktreePath === null; + const convertToJj = useCallback(() => { + if (!selectedThreadCwd || !selectedThread || isConverting) return; + Alert.alert( + "Enable Jujutsu?", + "Jujutsu will use this repository's Git store. Commits, branches, remotes, and uncommitted files stay in place. Existing Git worktrees continue to use Git.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Enable Jujutsu", + onPress: () => { + setIsConverting(true); + void initRepository({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd, kind: "jj" }, + }).then(async (result) => { + setIsConverting(false); + if (result._tag === "Success") { + await gitActions.refreshSelectedThreadGitStatus(); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Jujutsu initialization failed", + error instanceof Error ? error.message : "An error occurred.", + ); + } + }); + }, + }, + ], + ); + }, [gitActions, initRepository, isConverting, selectedThread, selectedThreadCwd]); // Deterministic pull-to-refresh state. Tying RefreshControl to the query's // isPending flag left the spinner stuck (the status query reports pending @@ -278,11 +305,23 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { /> ) : null} + {canConvertToJj ? ( + <> + {Platform.OS !== "android" ? : null} + + + ) : null} {Platform.OS !== "android" ? : null} { const params = { environmentId, threadId }; @@ -296,8 +335,8 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { {Platform.OS !== "android" ? : null} navigation.navigate("GitBranches", { @@ -326,8 +365,8 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { tintColorClassName="accent-foreground-muted" /> - {chain.kind === "native" ? "Stack" : "Branch stack"} · {chain.layers.length} PRs - · bottom to top + {chain.kind === "native" ? "Stack" : `${vcsTerminology.refNounTitle} stack`} ·{" "} + {chain.layers.length} PRs · bottom to top ) : null} @@ -354,7 +393,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ) : null} - {currentWorktreePath ? : null} + {currentWorktreePath ? ( + + ) : null} ); diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index c358466e22d2..bba3dacf5d26 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -1,4 +1,5 @@ import { SymbolView } from "../../../components/AppSymbol"; +import { DEFAULT_VCS_TERMINOLOGY, type VcsTerminology } from "@t3tools/shared/vcs"; import type { ComponentProps } from "react"; import { Platform, Pressable, View } from "react-native"; import { AppText as Text } from "../../../components/AppText"; @@ -142,13 +143,14 @@ export function statusSummary( readonly behindCount?: number; readonly pr?: { readonly state?: string; readonly number?: number } | null; } | null, + terminology: VcsTerminology = DEFAULT_VCS_TERMINOLOGY, ): string { if (!gitStatus) { - return "Loading branch status\u2026"; + return `Loading ${terminology.refNoun} status\u2026`; } if (!gitStatus.isRepo) { - return "Not a git repository"; + return `Not a ${terminology.systemName} repository`; } const parts: string[] = []; diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts index f48ba92396e9..025c6b72591d 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts @@ -1,12 +1,34 @@ import { describe, expect, it } from "vite-plus/test"; +import { JJ_VCS_TERMINOLOGY } from "@t3tools/shared/vcs"; + import { filterNewTaskBranches, resolveNewTaskBranchWorktreePath, resolveNewTaskBranchLabel, resolveNewTaskLocalWorkspaceSelection, + resolveNewTaskWorkspaceLabel, } from "./new-task-context-presentation"; +describe("new task VCS nouns", () => { + it("names the local-mode workspace with jj nouns", () => { + expect( + resolveNewTaskWorkspaceLabel({ + workspaceMode: "local", + worktreePath: null, + terminology: JJ_VCS_TERMINOLOGY, + }), + ).toBe("Current working copy"); + expect( + resolveNewTaskWorkspaceLabel({ + workspaceMode: "local", + worktreePath: "/ws", + terminology: JJ_VCS_TERMINOLOGY, + }), + ).toBe("Current workspace"); + }); +}); + describe("resolveNewTaskLocalWorkspaceSelection", () => { it("waits for refs instead of carrying a worktree base into Current checkout", () => { expect( diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts index 87c9b2595f4f..941a01715c26 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -1,15 +1,20 @@ import { sanitizeNewRefName } from "@t3tools/shared/git"; +import { DEFAULT_VCS_TERMINOLOGY, type VcsTerminology } from "@t3tools/shared/vcs"; type WorkspaceMode = "local" | "worktree"; export function resolveNewTaskWorkspaceLabel(input: { readonly workspaceMode: WorkspaceMode; readonly worktreePath: string | null; -}): "Current checkout" | "Current worktree" | "New worktree" { + readonly terminology?: VcsTerminology; +}): string { + const terminology = input.terminology ?? DEFAULT_VCS_TERMINOLOGY; if (input.workspaceMode === "worktree") { - return "New worktree"; + return `New ${terminology.workspaceNoun}`; } - return input.worktreePath ? "Current worktree" : "Current checkout"; + return input.worktreePath + ? `Current ${terminology.workspaceNoun}` + : `Current ${terminology.currentRefFallback}`; } export function resolveNewTaskBranchWorktreePath(input: { @@ -63,9 +68,10 @@ export function resolveNewTaskBranchLabel(input: { readonly branchName: string | null; readonly startFromOrigin: boolean; readonly workspaceMode: WorkspaceMode; + readonly terminology?: VcsTerminology; }): string { if (!input.branchName) { - return "Choose branch"; + return `Choose ${(input.terminology ?? DEFAULT_VCS_TERMINOLOGY).refNoun}`; } if (input.workspaceMode === "local") { diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 61b77afffba1..60410fd38623 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -65,6 +65,11 @@ import { } from "../../state/pending-task-editor-writes"; import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; import { vcsEnvironment } from "../../state/vcs"; +import { + DEFAULT_VCS_TERMINOLOGY, + resolveVcsTerminology, + type VcsTerminology, +} from "@t3tools/shared/vcs"; import { flattenQueuedThreadMessages, threadOutboxManager, @@ -124,12 +129,13 @@ function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null { export function branchBadgeLabel(input: { readonly branch: VcsRef; readonly project: EnvironmentProject | null; + readonly terminology?: VcsTerminology; }): string | null { if (input.branch.current) { return "current"; } if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) { - return "worktree"; + return (input.terminology ?? DEFAULT_VCS_TERMINOLOGY).workspaceNoun; } if (input.branch.isRemote) { return "remote"; @@ -161,6 +167,7 @@ type NewTaskFlowContextValue = { readonly hasMoreBranches: boolean; readonly availableBranches: ReadonlyArray; readonly currentCheckoutBranchName: string | null; + readonly vcsTerminology: VcsTerminology; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly planModeEnabled: boolean; @@ -674,6 +681,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { : null, ); const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null; + const vcsTerminology = resolveVcsTerminology(projectGitStatus.data); const filteredBranches = useMemo( () => filterNewTaskBranches(allBranchRefs, branchQuery), @@ -1181,6 +1189,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProviderStatus, providerGroups, filteredBranches, + vcsTerminology, reset, setProject, openDraft, @@ -1218,6 +1227,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { buildPendingTaskMessage, cancelEditingPendingTask, currentCheckoutBranchName, + vcsTerminology, editingPendingTask, environments, expandedProvider, diff --git a/apps/mobile/src/features/threads/worktree-setup-card.tsx b/apps/mobile/src/features/threads/worktree-setup-card.tsx index 86b71992710f..1c19114acd42 100644 --- a/apps/mobile/src/features/threads/worktree-setup-card.tsx +++ b/apps/mobile/src/features/threads/worktree-setup-card.tsx @@ -1,3 +1,4 @@ +import { DEFAULT_VCS_TERMINOLOGY, type VcsTerminology } from "@t3tools/shared/vcs"; import { worktreeSetupStageLabel, type WorktreeSetupSnapshot, @@ -14,6 +15,7 @@ import { WorktreeSetupSheet } from "./worktree-setup-sheet"; import { ShimmeringWorkContent } from "./thread-work-log"; export interface WorktreeSetupCardProps { + vcsTerminology?: VcsTerminology; snapshot: WorktreeSetupSnapshot; turnStarted: boolean; turnStartedAt: string | null; @@ -38,7 +40,13 @@ const icons: Record = { /** Setup stages collapse into the working header once the agent's turn is live. */ export function WorktreeSetupCard(props: WorktreeSetupCardProps) { - const { snapshot, turnStarted, turnStartedAt, working } = props; + const { + snapshot, + turnStarted, + turnStartedAt, + working, + vcsTerminology = DEFAULT_VCS_TERMINOLOGY, + } = props; const handedOff = turnStarted && worktreeSetupAgentStarted(snapshot); const running = snapshot.phase === "running"; const backgroundSetup = handedOff && running; @@ -53,17 +61,17 @@ export function WorktreeSetupCard(props: WorktreeSetupCardProps) { : running ? handedOff ? "Setup continues…" - : "Setting up worktree…" + : `Setting up ${vcsTerminology.workspaceNoun}…` : snapshot.phase === "cancelled" - ? "Worktree setup cancelled" + ? `${vcsTerminology.workspaceNounTitle} setup cancelled` : snapshot.phase === "failed" - ? "Worktree setup failed" + ? `${vcsTerminology.workspaceNounTitle} setup failed` : failed ? "Setup script failed" - : "Worktree ready"; + : `${vcsTerminology.workspaceNounTitle} ready`; return ( - + setDetailsOpen(true)} @@ -126,6 +134,7 @@ export function WorktreeSetupCard(props: WorktreeSetupCardProps) { + setBodyHeight(height)} @@ -225,7 +239,12 @@ function SetupDetailsSheet({ .filter((stage) => stage.id !== "agent") .map((stage) => ( - + {stage.id === "setup-script" && (stage.status === "running" || stage.status === "failed" || stage.tail.length > 0) ? ( @@ -241,7 +260,7 @@ function SetupDetailsSheet({ { onClose(); onCancel(); @@ -271,21 +290,25 @@ function SetupDetailsSheet({ function StageRow({ stage, + vcsTerminology, scriptName, now, compact = false, animate = true, }: { stage: WorktreeSetupStage; + vcsTerminology: VcsTerminology; scriptName: string | null; now: number; compact?: boolean; animate?: boolean; }) { const label = - stage.id === "setup-script" - ? (scriptName ?? worktreeSetupStageLabel(stage.id)) - : worktreeSetupStageLabel(stage.id); + stage.id === "fetch" + ? `Fetch base ${vcsTerminology.refNoun}` + : stage.id === "setup-script" + ? (scriptName ?? worktreeSetupStageLabel(stage.id)) + : worktreeSetupStageLabel(stage.id); const detail = stage.status === "pending" ? null diff --git a/apps/mobile/src/features/threads/worktree-setup-sheet.android.tsx b/apps/mobile/src/features/threads/worktree-setup-sheet.android.tsx index 267414a6ad10..802808002af6 100644 --- a/apps/mobile/src/features/threads/worktree-setup-sheet.android.tsx +++ b/apps/mobile/src/features/threads/worktree-setup-sheet.android.tsx @@ -9,7 +9,7 @@ const NativeBottomSheet = withUniwind(ModalBottomSheet, { containerColor: { fromClassName: "containerColorClassName", styleProperty: "accentColor" }, }); -export function WorktreeSetupSheet({ children, height, onClose }: WorktreeSetupSheetProps) { +export function WorktreeSetupSheet({ children, height, onClose, title }: WorktreeSetupSheetProps) { const window = useWindowDimensions(); return ( @@ -26,7 +26,7 @@ export function WorktreeSetupSheet({ children, height, onClose }: WorktreeSetupS }} > void; } -export function WorktreeSetupSheet({ children, height, onClose }: WorktreeSetupSheetProps) { +export function WorktreeSetupSheet({ children, height, onClose, title }: WorktreeSetupSheetProps) { const [headerHeight, setHeaderHeight] = useState(44); return ( {children} (null); const runStackedAction = useAtomCommand( vcsActionManager.runStackedAction({ environmentId: selectedThread?.environmentId ?? null, @@ -45,6 +50,11 @@ export function useSelectedThreadGitActions() { { reportFailure: false }, ); + const vcsTerminology = useVcsTerminology( + selectedThread?.environmentId ?? null, + selectedThreadCwd, + ); + const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null; const branchTarget = useMemo( () => ({ @@ -105,14 +115,17 @@ export function useSelectedThreadGitActions() { ); if (AsyncResult.isFailure(result)) { const error = Cause.squash(result.cause); - const message = error instanceof Error ? error.message : "Failed to refresh git status."; + const message = + error instanceof Error + ? error.message + : `Failed to refresh ${vcsTerminology.systemName} status.`; setPendingConnectionError(message); return null; } setPendingConnectionError(null); return result.value; }, - [refreshStatus, selectedThread, selectedThreadCwd, selectedThreadProject], + [refreshStatus, selectedThread, selectedThreadCwd, selectedThreadProject, vcsTerminology], ); useEffect(() => { @@ -197,7 +210,7 @@ export function useSelectedThreadGitActions() { async (branch: string) => { await runSelectedThreadGitMutation( "switch_ref", - "Switching branch", + `Switching ${vcsTerminology.refNoun}`, async ({ thread, cwd }) => { const result = await switchRef({ environmentId: thread.environmentId, @@ -223,6 +236,7 @@ export function useSelectedThreadGitActions() { selectedThreadWorktreePath, syncSelectedThreadBranchState, switchRef, + vcsTerminology, ], ); @@ -230,7 +244,7 @@ export function useSelectedThreadGitActions() { async (branch: string) => { await runSelectedThreadGitMutation( "create_ref", - "Creating branch", + `Creating ${vcsTerminology.refNoun}`, async ({ thread, cwd }) => { const result = await createRef({ environmentId: thread.environmentId, @@ -256,6 +270,7 @@ export function useSelectedThreadGitActions() { selectedThreadWorktreePath, syncSelectedThreadBranchState, createRef, + vcsTerminology, ], ); @@ -263,7 +278,7 @@ export function useSelectedThreadGitActions() { async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { await runSelectedThreadGitMutation( "create_worktree", - "Creating worktree", + `Creating ${vcsTerminology.workspaceNoun}`, async ({ thread, project }) => { const result = await createWorktree({ environmentId: thread.environmentId, @@ -289,9 +304,98 @@ export function useSelectedThreadGitActions() { }, ); }, - [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState], + [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState, vcsTerminology], ); + // Creating a workspace on a phone must have a way back out, or one made here + // could only be cleaned up from the web client. + const onRemoveSelectedThreadWorkspace = useCallback(async () => { + const thread = selectedThread; + const project = selectedThreadProject; + const worktreePath = selectedThreadWorktreePath; + if (!thread || !project || !worktreePath) { + return false; + } + if (pendingWorkspaceMetadataCleanup !== worktreePath) { + const confirmed = await new Promise((resolve) => { + Alert.alert( + `Remove this ${vcsTerminology.workspaceNoun}?`, + `${worktreePath}\n\nThe thread stays; only the ${vcsTerminology.workspaceNoun} is deleted.`, + [ + { text: "Cancel", style: "cancel", onPress: () => resolve(false) }, + { text: "Remove", style: "destructive", onPress: () => resolve(true) }, + ], + ); + }); + if (!confirmed) return false; + + let result = await removeWorktree({ + environmentId: thread.environmentId, + input: { cwd: project.workspaceRoot, path: worktreePath, force: false }, + }); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + const message = error instanceof Error ? error.message : "An error occurred."; + if (message.includes("uncommitted or unbookmarked changes")) { + const discard = await new Promise((resolve) => { + Alert.alert( + `Discard changes and remove ${vcsTerminology.workspaceNoun}?`, + `Uncommitted or unbookmarked changes in ${worktreePath} will be lost.`, + [ + { text: "Cancel", style: "cancel", onPress: () => resolve(false) }, + { text: "Discard and remove", style: "destructive", onPress: () => resolve(true) }, + ], + ); + }); + if (!discard) return false; + result = await removeWorktree({ + environmentId: thread.environmentId, + input: { cwd: project.workspaceRoot, path: worktreePath, force: true }, + }); + } + } + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + showGitActionResult({ + type: "error", + title: `Failed to remove ${vcsTerminology.workspaceNoun}`, + description: error instanceof Error ? error.message : "An error occurred.", + }); + return false; + } + } + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd: project.workspaceRoot, + nextThreadState: { worktreePath: null }, + }); + if (AsyncResult.isFailure(syncResult)) { + setPendingWorkspaceMetadataCleanup(worktreePath); + const error = Cause.squash(syncResult.cause); + showGitActionResult({ + type: "error", + title: `${vcsTerminology.workspaceNounTitle} removed, but thread update failed`, + description: + error instanceof Error ? error.message : "Try clearing the thread workspace again.", + }); + return false; + } + setPendingWorkspaceMetadataCleanup(null); + showGitActionResult({ + type: "success", + title: `${vcsTerminology.workspaceNounTitle} removed`, + }); + return true; + }, [ + pendingWorkspaceMetadataCleanup, + removeWorktree, + selectedThread, + selectedThreadProject, + selectedThreadWorktreePath, + syncSelectedThreadBranchState, + vcsTerminology, + ]); + const onPullSelectedThreadBranch = useCallback(async () => { await runSelectedThreadGitMutation( "pull", @@ -380,6 +484,8 @@ export function useSelectedThreadGitActions() { onCheckoutSelectedThreadBranch, onCreateSelectedThreadBranch, onCreateSelectedThreadWorktree, + onRemoveSelectedThreadWorkspace, + pendingWorkspaceMetadataCleanup: pendingWorkspaceMetadataCleanup === selectedThreadWorktreePath, onPullSelectedThreadBranch, onRunSelectedThreadGitAction, }; diff --git a/apps/mobile/src/state/use-selected-thread-git-state.ts b/apps/mobile/src/state/use-selected-thread-git-state.ts index a8c037db6f77..38661baa16be 100644 --- a/apps/mobile/src/state/use-selected-thread-git-state.ts +++ b/apps/mobile/src/state/use-selected-thread-git-state.ts @@ -5,6 +5,7 @@ import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { useBranches } from "./queries"; import { useEnvironmentQuery } from "./query"; import { sourceControlEnvironment } from "./sourceControl"; +import { useVcsTerminology } from "./vcs"; import { useVcsActionState } from "./use-vcs-action-state"; import { useThreadSelection } from "./use-thread-selection"; import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; @@ -30,6 +31,11 @@ export function useSelectedThreadGitState() { }), ); + const vcsTerminology = useVcsTerminology( + selectedThread?.environmentId ?? null, + selectedThreadCwd, + ); + const selectedThreadBranchTarget = useMemo( () => ({ environmentId: selectedThread?.environmentId ?? null, @@ -49,6 +55,7 @@ export function useSelectedThreadGitState() { return { gitOperationLabel: gitActionState.currentLabel, + vcsTerminology, sourceControlDiscovery, selectedThreadBranches, selectedThreadBranchesLoading: selectedThreadBranchState.isPending, diff --git a/apps/mobile/src/state/vcs.ts b/apps/mobile/src/state/vcs.ts index dc8c251149f6..fd8abd6d2e39 100644 --- a/apps/mobile/src/state/vcs.ts +++ b/apps/mobile/src/state/vcs.ts @@ -1,9 +1,25 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import { createVcsActionManager, createVcsEnvironmentAtoms, } from "@t3tools/client-runtime/state/vcs"; +import { resolveVcsTerminology, type VcsTerminology } from "@t3tools/shared/vcs"; import { connectionAtomRuntime } from "../connection/runtime"; +import { useEnvironmentQuery } from "./query"; export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); + +/** Reads the shared status atom for (environment, cwd); Git terms until it answers. */ +export function useVcsTerminology( + environmentId: EnvironmentId | null, + cwd: string | null, +): VcsTerminology { + const status = useEnvironmentQuery( + environmentId === null || cwd === null + ? null + : vcsEnvironment.status({ environmentId, input: { cwd } }), + ); + return resolveVcsTerminology(status.data); +} diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 1e116a91fea7..5d81b009ff53 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -9,7 +9,10 @@ import { type OrchestrationEvent, type OrchestrationThread, type ProviderApprovalDecision, + type ServerSettingsPatch, } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -58,6 +61,7 @@ import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts" import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; +import { ProviderCommandReactor } from "../src/orchestration/Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; import { CheckpointReactor } from "../src/orchestration/Services/CheckpointReactor.ts"; import { ProviderRuntimeIngestionService } from "../src/orchestration/Services/ProviderRuntimeIngestion.ts"; @@ -85,7 +89,14 @@ import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts"; import * as WorkspacePaths from "../src/workspace/WorkspacePaths.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; -import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; +import { GitWorkflowService, layer as GitWorkflowLayer } from "../src/git/GitWorkflowService.ts"; +import * as GitManager from "../src/git/GitManager.ts"; +import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; +import * as JjVcsDriver from "../src/vcs/JjVcsDriver.ts"; +import * as JjWorkflow from "../src/jj/JjWorkflow.ts"; +import * as SourceControlProvider from "../src/sourceControl/SourceControlProvider.ts"; +import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; +import * as ProjectSetupScriptRunner from "../src/project/ProjectSetupScriptRunner.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; import * as PullRequestService from "../src/pullRequest/PullRequestService.ts"; @@ -104,6 +115,7 @@ const initializeGitWorkspace = Effect.fn(function* (cwd: string) { runGit(cwd, ["init", "--initial-branch=main"]); runGit(cwd, ["config", "user.email", "test@example.com"]); runGit(cwd, ["config", "user.name", "Test User"]); + runGit(cwd, ["config", "commit.gpgsign", "false"]); const fileSystem = yield* FileSystem.FileSystem; const { join } = yield* Path.Path; yield* fileSystem.writeFileString(join(cwd, "README.md"), "v1\n"); @@ -188,6 +200,11 @@ export interface OrchestrationIntegrationHarness { readonly snapshotQuery: ProjectionSnapshotQuery["Service"]; readonly providerService: ProviderService["Service"]; readonly checkpointStore: CheckpointStore.CheckpointStore["Service"]; + readonly workflow: GitWorkflowService["Service"]; + readonly vcs: VcsDriverRegistry.VcsDriverRegistry["Service"]; + readonly settings: ServerSettingsService["Service"]; + readonly storageCleanup: StorageCleanup.StorageCleanup["Service"]; + readonly drainProviderCommands: Effect.Effect; readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"]; readonly waitForThread: ( threadId: string, @@ -230,6 +247,10 @@ export interface OrchestrationIntegrationHarness { } interface MakeOrchestrationIntegrationHarnessOptions { + readonly realVcs?: boolean; + readonly rootDir?: string; + readonly settings?: ServerSettingsPatch; + readonly sourceControlProvider?: SourceControlProvider.SourceControlProvider["Service"]; readonly provider?: ProviderDriverKind; readonly realCodex?: boolean; /** Tracer for every fiber the harness runtime runs, including reactors. */ @@ -256,16 +277,19 @@ export const makeOrchestrationIntegrationHarness = ( makeAdapterRegistryMock({ [adapterHarness.provider]: adapterHarness.adapter }), ) : null; - const rootDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-orchestration-integration-", - }); + const directory = + options?.rootDir ?? + (yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-orchestration-integration-", + })); + const rootDir = yield* fileSystem.realPath(directory); const workspaceDir = path.join(rootDir, "workspace"); const { stateDir, dbPath } = yield* deriveServerPaths(rootDir, undefined).pipe( Effect.provideService(Path.Path, path), ); yield* fileSystem.makeDirectory(workspaceDir, { recursive: true }); yield* fileSystem.makeDirectory(stateDir, { recursive: true }); - yield* initializeGitWorkspace(workspaceDir); + if (options?.rootDir === undefined) yield* initializeGitWorkspace(workspaceDir); const persistenceLayer = makeSqlitePersistenceLive(dbPath); const orchestrationLayer = OrchestrationEngineLive.pipe( @@ -291,23 +315,18 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(providerSessionDirectoryLayer), ); const providerEventLoggersLayer = Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers); - const providerLayer = useRealCodex - ? makeProviderServiceLive().pipe( - Layer.provide(providerSessionDirectoryLayer), - Layer.provide(realCodexRegistry), - Layer.provide(AnalyticsService.layerTest), - Layer.provide(providerEventLoggersLayer), - ) - : makeProviderServiceLive().pipe( - Layer.provide(providerSessionDirectoryLayer), - Layer.provide(fakeRegistry!), - Layer.provide(AnalyticsService.layerTest), - Layer.provide(providerEventLoggersLayer), - ); + const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(projectionSnapshotQueryLayer), + Layer.provide(VcsDriverRegistry.layer), + Layer.provide(providerSessionDirectoryLayer), + Layer.provide(fakeRegistry ?? realCodexRegistry), + Layer.provide(AnalyticsService.layerTest), + Layer.provide(providerEventLoggersLayer), + ); const providerRegistryLayer = makeProviderRegistryLayer(); const checkpointStoreLayer = CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer)); - const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, orchestrationLayer.pipe(Layer.provide(projectionSnapshotQueryLayer)), @@ -324,7 +343,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(serverSettingsLayer), ); - const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ + const mockGitWorkflowLayer = Layer.mock(GitWorkflowService)({ renameBranch: (input: { readonly cwd: string; readonly oldBranch: string; @@ -334,14 +353,73 @@ export const makeOrchestrationIntegrationHarness = ( const textGenerationLayer = Layer.succeed(TextGeneration, { generateBranchName: () => Effect.succeed({ branch: "update" }), generateThreadTitle: () => Effect.succeed({ title: "New thread" }), - } as unknown as TextGeneration["Service"]); + generateCommitMessage: () => Effect.succeed({ subject: "Update workspace", body: "" }), + generatePrContent: () => + Effect.succeed({ title: "Update workspace", body: "Workspace changes" }), + }); + const sourceControlProvider = + options?.sourceControlProvider ?? + (yield* SourceControlProvider.SourceControlProvider.pipe( + Effect.provide( + Layer.mock(SourceControlProvider.SourceControlProvider)({ kind: "unknown" }), + ), + )); + const sourceControlLayer = Layer.mock( + SourceControlProviderRegistry.SourceControlProviderRegistry, + )({ + resolveLink: () => undefined, + get: () => Effect.succeed(sourceControlProvider), + resolve: () => Effect.succeed(sourceControlProvider), + resolveHandle: () => Effect.succeed({ provider: sourceControlProvider, context: null }), + }); + const gitManagerLayer = GitManager.layer.pipe( + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(sourceControlLayer), + Layer.provideMerge(textGenerationLayer), + Layer.provideMerge( + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread: () => Effect.succeed({ status: "no-script" }), + }), + ), + ); + const gitWorkflowLayer = options?.realVcs + ? GitWorkflowLayer.pipe( + Layer.provideMerge( + JjWorkflow.layer.pipe( + Layer.provideMerge(JjVcsDriver.layer), + Layer.provideMerge(gitManagerLayer), + ), + ), + Layer.provideMerge(VcsDriverRegistry.layer), + ) + : mockGitWorkflowLayer; + const terminalLayer = Layer.mock(TerminalManager.TerminalManager)({ + closeIdle: () => Effect.void, + subscribeMetadata: (listener) => + listener({ type: "snapshot", terminals: [] }).pipe(Effect.as(() => {})), + }); + const deletionLayer = Layer.succeed(ThreadDeletionReactor, { + start: () => Effect.void, + drainThrough: () => Effect.void, + }); + const cleanupLayer = options?.realVcs + ? StorageCleanup.layer.pipe( + Layer.provide(gitWorkflowLayer), + Layer.provide(runtimeServicesLayer), + Layer.provide(terminalLayer), + Layer.provide(deletionLayer), + ) + : Layer.succeed(StorageCleanup.StorageCleanup, { + start: () => Effect.void, + drain: Effect.void, + }); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provide( Layer.mock(ProviderAuthService)({ tryHandlePromptCommand: () => Effect.succeed(false), }), ), - Layer.provide(Layer.mock(TerminalManager.TerminalManager)({ closeIdle: () => Effect.void })), + Layer.provide(terminalLayer), Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), @@ -383,21 +461,11 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(VcsProcess.layer), ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( - Layer.provideMerge( - Layer.succeed(StorageCleanup.StorageCleanup, { - start: () => Effect.void, - drain: Effect.void, - }), - ), + Layer.provideMerge(cleanupLayer), Layer.provideMerge(runtimeIngestionLayer), Layer.provideMerge(providerCommandReactorLayer), Layer.provideMerge(checkpointReactorLayer), - Layer.provideMerge( - Layer.succeed(ThreadDeletionReactor, { - start: () => Effect.void, - drainThrough: () => Effect.void, - }), - ), + Layer.provideMerge(deletionLayer), Layer.provideMerge( Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, { start: () => Effect.void, @@ -427,10 +495,13 @@ export const makeOrchestrationIntegrationHarness = ( const layer = Layer.empty.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(orchestrationReactorLayer), + Layer.provideMerge(gitWorkflowLayer), + Layer.provideMerge(VcsDriverRegistry.layer), Layer.provideMerge(providerRegistryLayer), Layer.provide(persistenceLayer), Layer.provideMerge(RepositoryIdentityResolver.layer), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(serverSettingsLayer), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge( @@ -468,16 +539,48 @@ export const makeOrchestrationIntegrationHarness = ( const runtimeReceiptBus = yield* tryRuntimePromise("load RuntimeReceiptBus service", () => runtime.runPromise(Effect.service(RuntimeReceiptBus)), ).pipe(Effect.orDie); + const runtimeContext = yield* tryRuntimePromise("load runtime context", runtime.context).pipe( + Effect.orDie, + ); + const workflow = Context.get(runtimeContext, GitWorkflowService); + const vcs = Context.get(runtimeContext, VcsDriverRegistry.VcsDriverRegistry); + const settings = Context.get(runtimeContext, ServerSettingsService); + const storageCleanup = Context.get(runtimeContext, StorageCleanup.StorageCleanup); + const providerCommands = Context.get(runtimeContext, ProviderCommandReactor); + if (options?.settings) yield* settings.updateSettings(options.settings); const scope = yield* Scope.make("sequential"); yield* tryRuntimePromise("start OrchestrationReactor", () => runtime.runPromise(reactor.start().pipe(Scope.provide(scope))), ).pipe(Effect.orDie); const receiptHistory = yield* Ref.make>([]); - yield* Stream.runForEach(runtimeReceiptBus.streamEventsForTest, (receipt) => - Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid), - ).pipe(Effect.forkIn(scope)); - yield* Effect.sleep(10); + const receiptWaiters = new Set<{ + readonly predicate: (receipt: OrchestrationRuntimeReceipt) => boolean; + readonly completion: Deferred.Deferred; + }>(); + const receipts = yield* Stream.toPull(runtimeReceiptBus.streamEventsForTest).pipe( + Scope.provide(scope), + ); + yield* receipts.pipe( + Effect.flatMap((batch) => + Effect.forEach( + batch, + (receipt) => + Ref.update(receiptHistory, (history) => [...history, receipt]).pipe( + Effect.andThen( + Effect.forEach( + [...receiptWaiters].filter((waiter) => waiter.predicate(receipt)), + (waiter) => Deferred.succeed(waiter.completion, receipt), + { discard: true }, + ), + ), + ), + { discard: true }, + ), + ), + Effect.forever, + Effect.forkIn(scope), + ); const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( threadId, @@ -560,16 +663,24 @@ export const makeOrchestrationIntegrationHarness = ( predicate: (receipt: OrchestrationRuntimeReceipt) => boolean, timeoutMs?: number, ) { - const readMatchingReceipt = Ref.get(receiptHistory).pipe( - Effect.map((history) => history.find(predicate)), - ); - - return waitFor( - readMatchingReceipt, - (receipt): receipt is OrchestrationRuntimeReceipt => receipt !== undefined, - "runtime receipt", - timeoutMs, - ); + return Effect.gen(function* () { + const completion = yield* Deferred.make(); + const waiter = { predicate, completion }; + receiptWaiters.add(waiter); + const existing = (yield* Ref.get(receiptHistory)).find(predicate); + if (existing) yield* Deferred.succeed(completion, existing); + const awaiting = + timeoutMs === undefined + ? Deferred.await(completion) + : Deferred.await(completion).pipe(Effect.timeout(`${timeoutMs} millis`), Effect.orDie); + return yield* awaiting.pipe( + Effect.ensuring( + Effect.sync(() => { + receiptWaiters.delete(waiter); + }), + ), + ); + }); } let disposed = false; @@ -606,6 +717,11 @@ export const makeOrchestrationIntegrationHarness = ( snapshotQuery, providerService, checkpointStore, + workflow, + vcs, + settings, + storageCleanup, + drainProviderCommands: providerCommands.drain, pendingApprovalRepository, waitForThread, waitForDomainEvent, diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 095cca4e5e74..1a4254667f28 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -9,6 +9,7 @@ import { ThreadId, TurnId, ProviderDriverKind, + type ProviderSendTurnInput, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Queue from "effect/Queue"; @@ -177,6 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti } export interface TestProviderAdapterHarness { + readonly getSendTurnInputs: () => ReadonlyArray; readonly adapter: ProviderAdapterShape; readonly provider: ProviderDriverKind; readonly queueTurnResponse: ( @@ -230,6 +232,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter let eventCount = 0; const sessions = new Map(); const queuedResponsesForNextSession: TestTurnResponse[] = []; + const sendTurnInputs: ProviderSendTurnInput[] = []; const interruptCallsBySession = new Map>(); const approvalResponsesBySession = new Map< ThreadId, @@ -290,6 +293,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { + sendTurnInputs.push(input); const state = sessions.get(input.threadId); if (!state) { return yield* missingSessionEffect(provider, input.threadId); @@ -560,6 +564,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter queueTurnResponse, queueTurnResponseForNextSession, getStartCount, + getSendTurnInputs: () => [...sendTurnInputs], getRollbackCalls, getInterruptCalls, listActiveSessionIds, diff --git a/apps/server/integration/jujutsu.integration.test.ts b/apps/server/integration/jujutsu.integration.test.ts new file mode 100644 index 000000000000..34c9a323fe8a --- /dev/null +++ b/apps/server/integration/jujutsu.integration.test.ts @@ -0,0 +1,385 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ChangeRequest, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; +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 Stream from "effect/Stream"; + +import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts"; +import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; +import { SourceControlProvider } from "../src/sourceControl/SourceControlProvider.ts"; +import { describeJj, commitId, runGit, runJj } from "../src/vcs/testing/JjTestSupport.ts"; +import { + makeOrchestrationIntegrationHarness, + type OrchestrationIntegrationHarness, +} from "./OrchestrationEngineHarness.integration.ts"; +import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts"; + +const projectId = ProjectId.make("mixed-vcs-project"); +const provider = ProviderDriverKind.make("codex"); +const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; +const old = "2026-01-01T00:00:00.000Z"; + +const seedThread = Effect.fnUntraced(function* ( + harness: OrchestrationIntegrationHarness, + name: string, + cwd: string, + branch: string, +) { + const threadId = ThreadId.make(name); + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create-${name}`), + threadId, + projectId, + title: name, + modelSelection, + interactionMode: "default", + runtimeMode: "full-access", + branch, + worktreePath: cwd, + createdAt: old, + }); + return threadId; +}); + +const mixedWorkspaces = Effect.fnUntraced(function* (harness: OrchestrationIntegrationHarness) { + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-project"), + projectId, + title: "Mixed VCS", + workspaceRoot: harness.workspaceDir, + defaultModelSelection: modelSelection, + createdAt: old, + }); + const git = (yield* harness.workflow.createWorktree({ + cwd: harness.workspaceDir, + refName: "main", + newRefName: "legacy-git", + path: null, + })).worktree; + yield* runJj(harness.workspaceDir, ["git", "init", "--colocate"]); + yield* harness.vcs.invalidate(harness.workspaceDir); + const jj = (yield* harness.workflow.createWorktree({ + cwd: harness.workspaceDir, + refName: "main", + newRefName: "topic-jj", + path: null, + })).worktree; + return { git, jj }; +}); + +const turn = Effect.fnUntraced(function* ( + harness: OrchestrationIntegrationHarness, + threadId: ThreadId, + count: number, + mutation: TestTurnResponse["mutateWorkspace"], +) { + const adapter = harness.adapterHarness; + assert.isNotNull(adapter); + if (adapter === null) return yield* Effect.die(new Error("Expected a deterministic provider")); + const createdAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const response: TestTurnResponse = { + events: [ + { + type: "turn.started", + eventId: EventId.make(`start-${threadId}-${count}`), + provider, + threadId, + turnId: "fixture", + createdAt, + }, + ], + ...(mutation ? { mutateWorkspace: mutation } : {}), + }; + if (adapter.listActiveSessionIds().includes(threadId)) + yield* adapter.queueTurnResponse(threadId, response); + else yield* adapter.queueTurnResponseForNextSession(response); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`turn-${threadId}-${count}`), + threadId, + message: { + messageId: MessageId.make(`message-${threadId}-${count}`), + role: "user", + text: "Update the workspace", + attachments: [], + }, + interactionMode: "default", + runtimeMode: "full-access", + createdAt, + }); + const finalized = yield* harness.waitForReceipt( + (receipt) => + receipt.type === "checkpoint.diff.finalized" && + receipt.threadId === threadId && + receipt.checkpointTurnCount === count, + ); + if (finalized.type === "checkpoint.diff.finalized") assert.equal(finalized.status, "ready"); + yield* harness.waitForReceipt( + (receipt) => + receipt.type === "turn.processing.quiesced" && + receipt.threadId === threadId && + receipt.checkpointTurnCount === count, + ); + yield* harness.drainProviderRuntime; +}); + +describeJj("Jujutsu orchestration integration", () => { + it.live( + "checkpoints and rewinds mixed-backend turns independently, reporting exclusions and refreshing hints", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const harness = yield* makeOrchestrationIntegrationHarness({ realVcs: true }); + yield* Effect.addFinalizer(() => harness.dispose); + const { git, jj } = yield* mixedWorkspaces(harness); + const gitThread = yield* seedThread(harness, "git-turns", git.path, git.refName); + const jjThread = yield* seedThread(harness, "jj-turns", jj.path, jj.refName); + const mainBefore = yield* fs.readFileString(path.join(harness.workspaceDir, "README.md")); + const gitHead = (yield* runGit(git.path, ["rev-parse", "HEAD"])).trim(); + yield* turn(harness, gitThread, 1, ({ cwd }) => + fs.writeFileString(path.join(cwd, "README.md"), "git turn\n").pipe(Effect.orDie), + ); + yield* turn(harness, jjThread, 1, ({ cwd }) => + Effect.all( + [ + fs.writeFileString(path.join(cwd, "README.md"), "jj turn\n"), + fs.writeFileString(path.join(cwd, "captured.bin"), "x".repeat(2 * 1024 * 1024)), + fs.writeFileString(path.join(cwd, "excluded.bin"), "x".repeat(10 * 1024 * 1024 + 1)), + ], + { discard: true }, + ).pipe(Effect.orDie), + ); + const detail = Option.getOrThrow( + yield* harness.snapshotQuery.getThreadDetailById(jjThread), + ); + assert.isTrue( + detail.activities.some( + (activity) => + activity.kind === "checkpoint.capture.excluded" && + JSON.stringify(activity.payload).includes("excluded.bin"), + ), + ); + const captured = yield* runGit(harness.workspaceDir, [ + "ls-tree", + "--name-only", + "-r", + checkpointRefForThreadTurn(jjThread, 1), + ]); + assert.include(captured, "captured.bin"); + assert.notInclude(captured, "excluded.bin"); + assert.include( + harness.adapterHarness?.getSendTurnInputs().find((input) => input.threadId === jjThread) + ?.vcsAgentHint ?? "", + "Jujutsu", + ); + assert.equal( + harness.adapterHarness?.getSendTurnInputs().find((input) => input.threadId === gitThread) + ?.vcsAgentHint, + null, + ); + yield* harness.settings.updateSettings({ enableVcsAgentHints: false }); + yield* turn(harness, jjThread, 2, ({ cwd }) => + fs.writeFileString(path.join(cwd, "README.md"), "jj second turn\n").pipe(Effect.orDie), + ); + assert.equal(harness.adapterHarness?.getSendTurnInputs().at(-1)?.vcsAgentHint, null); + yield* harness.settings.updateSettings({ + projectSettingsOverrides: { [projectId]: { enableVcsAgentHints: true } }, + }); + yield* turn(harness, jjThread, 3, undefined); + assert.include( + harness.adapterHarness?.getSendTurnInputs().at(-1)?.vcsAgentHint ?? "", + "Jujutsu", + ); + const events = yield* harness.engine.subscribeDomainEvents; + const pull = yield* Stream.toPull(events); + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("rewind-jj"), + threadId: jjThread, + turnCount: 1, + createdAt: yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)), + }); + for (;;) { + if ( + (yield* pull).some( + (event) => event.type === "thread.reverted" && event.aggregateId === jjThread, + ) + ) + break; + } + yield* harness.drainCheckpointReactor; + assert.equal(yield* fs.readFileString(path.join(jj.path, "README.md")), "jj turn\n"); + assert.isTrue(yield* fs.exists(path.join(jj.path, "excluded.bin"))); + assert.equal(yield* fs.readFileString(path.join(git.path, "README.md")), "git turn\n"); + assert.equal((yield* runGit(git.path, ["rev-parse", "HEAD"])).trim(), gitHead); + assert.equal( + yield* fs.readFileString(path.join(harness.workspaceDir, "README.md")), + mainBefore, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + 40_000, + ); + + it.live( + "cleans committed work safely and recreates each original backend after a SQLite-backed restart", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const first = yield* makeOrchestrationIntegrationHarness({ realVcs: true }); + yield* Effect.addFinalizer(() => first.dispose); + const { git, jj } = yield* mixedWorkspaces(first); + const gitThread = yield* seedThread(first, "git-resume", git.path, git.refName); + const jjThread = yield* seedThread(first, "jj-resume", jj.path, jj.refName); + yield* fs.writeFileString(path.join(git.path, "git-work.txt"), "committed git\n"); + yield* runGit(git.path, ["add", "."]); + yield* runGit(git.path, ["commit", "-m", "Git thread work"]); + yield* fs.writeFileString(path.join(jj.path, "jj-work.txt"), "committed jj\n"); + yield* runJj(jj.path, ["commit", "-m", "Unbookmarked thread work"]); + yield* first.dispose; + const restarted = yield* makeOrchestrationIntegrationHarness({ + realVcs: true, + rootDir: first.rootDir, + settings: { storageCleanup: { worktreeAfterDays: 1 }, enableVcsAgentHints: false }, + }); + yield* Effect.addFinalizer(() => restarted.dispose); + yield* restarted.storageCleanup.drain; + assert.isFalse(yield* fs.exists(git.path)); + assert.isFalse(yield* fs.exists(jj.path)); + const saved = Option.getOrThrow( + yield* restarted.snapshotQuery.getThreadDetailById(jjThread), + ); + assert.equal(saved.worktreePath, jj.path); + assert.equal(saved.branch, jj.refName); + yield* turn(restarted, gitThread, 1, undefined); + yield* turn(restarted, jjThread, 1, undefined); + assert.equal((yield* restarted.vcs.resolve({ cwd: git.path })).kind, "git"); + assert.equal((yield* restarted.vcs.resolve({ cwd: jj.path })).kind, "jj"); + assert.equal( + yield* fs.readFileString(path.join(git.path, "git-work.txt")), + "committed git\n", + ); + assert.equal(yield* fs.readFileString(path.join(jj.path, "jj-work.txt")), "committed jj\n"); + assert.isFalse(yield* fs.exists(path.join(jj.path, ".git"))); + assert.isTrue( + restarted.adapterHarness + ?.getSendTurnInputs() + .every((input) => input.vcsAgentHint === null), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + 40_000, + ); + + it.live( + "publishes a secondary-workspace bookmark and opens its request with synchronized Git refs", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let created = false; + let expectedHead = ""; + const request: ChangeRequest = { + provider: "github", + number: 1, + title: "Workspace changes", + url: "https://github.com/test/project/pull/1", + baseRefName: "main", + headRefName: "topic-jj", + state: "open", + updatedAt: Option.some(DateTime.makeUnsafe(old)), + }; + const sourceControlProvider = yield* SourceControlProvider.pipe( + Effect.provide( + Layer.mock(SourceControlProvider)({ + kind: "github", + listChangeRequests: () => Effect.succeed(created ? [request] : []), + getDefaultBranch: () => Effect.succeed("main"), + getChangeRequest: () => Effect.succeed({ ...request, headRefName: "review-head" }), + createChangeRequest: ({ cwd, headSelector }) => + Effect.gen(function* () { + assert.equal(headSelector, "topic-jj"); + assert.equal( + (yield* runGit(cwd, ["rev-parse", "refs/heads/topic-jj"])).trim(), + expectedHead, + ); + created = true; + }).pipe(Effect.orDie), + }), + ), + ); + const harness = yield* makeOrchestrationIntegrationHarness({ + realVcs: true, + sourceControlProvider, + }); + yield* Effect.addFinalizer(() => harness.dispose); + const { jj } = yield* mixedWorkspaces(harness); + const threadId = yield* seedThread(harness, "jj-review-source", jj.path, jj.refName); + const remote = path.join(harness.rootDir, "remote.git"); + yield* runGit(harness.workspaceDir, ["init", "--bare", "--initial-branch=main", remote]); + yield* runJj(jj.path, ["git", "remote", "add", "origin", remote]); + yield* runJj(jj.path, ["git", "push", "--bookmark", "main", "--remote", "origin"]); + const previous = (yield* runGit(harness.workspaceDir, [ + "rev-parse", + "refs/heads/topic-jj", + ])).trim(); + yield* fs.writeFileString(path.join(jj.path, "publish.txt"), "publish me\n"); + yield* fs.writeFileString( + path.join(jj.path, ".gitmodules"), + '[submodule "example"]\npath = example\nurl = https://example.com/example.git\n', + ); + yield* runJj(jj.path, ["commit", "-m", "Publish workspace work"]); + expectedHead = yield* commitId(jj.path, "@-"); + yield* runJj(jj.path, ["bookmark", "set", "topic-jj", "-r", "@-"]); + yield* harness.workflow.invalidateStatus(jj.path); + assert.equal( + (yield* runGit(harness.workspaceDir, ["rev-parse", "refs/heads/topic-jj"])).trim(), + previous, + ); + const result = yield* harness.workflow.runStackedAction({ + cwd: jj.path, + action: "commit_push_pr", + actionId: "publish-request", + }); + assert.equal(result.push.status, "pushed"); + assert.equal(result.pr.status, "created"); + assert.equal( + (yield* runGit(remote, ["rev-parse", "refs/heads/topic-jj"])).trim(), + expectedHead, + ); + assert.equal( + (yield* runGit(harness.workspaceDir, ["rev-parse", "refs/heads/topic-jj"])).trim(), + expectedHead, + ); + assert.isTrue(created); + yield* harness.settings.updateSettings({ + worktreeSubmodules: "recursive", + projectSettingsOverrides: { [projectId]: { worktreeSubmodules: "none" } }, + }); + yield* runGit(remote, ["update-ref", "refs/heads/review-head", expectedHead]); + const review = yield* harness.workflow + .preparePullRequestThread({ cwd: jj.path, reference: "1", mode: "worktree", threadId }) + .pipe(Effect.provideService(ProjectionSnapshotQuery, harness.snapshotQuery)); + assert.isNotNull(review.worktreePath); + if (review.worktreePath !== null) + assert.isTrue(yield* fs.exists(path.join(review.worktreePath, ".gitmodules"))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + 40_000, + ); +}); diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index b483a3f99f8e..00511d8bbf6a 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -44,7 +44,7 @@ import * as ServerLifecycleEvents from "../src/serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts"; import * as ServerSettings from "../src/serverSettings.ts"; import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts"; -import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; +import * as GitWorkflowService from "../src/git/GitWorkflowService.ts"; const providerInstanceId = ProviderInstanceId.make("codex"); const projectId = ProjectId.make("project-startup-orphan"); @@ -110,7 +110,7 @@ const startupDependencies = Layer.mergeAll( }), ), AnalyticsService.layerTest, - Layer.mock(GitVcsDriver.GitVcsDriver)({}), + Layer.mock(GitWorkflowService.GitWorkflowService)({}), Layer.succeed(ProviderService.ProviderService, { startSession: () => Effect.die("unused"), sendTurn: () => Effect.die("unused"), diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 0d041b36ecb8..ccf3efde4cd2 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -28,6 +28,8 @@ import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; +import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import { makeTestProviderAdapterHarness, @@ -101,6 +103,8 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer { }> = []; const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, + supportsCheckpoints: () => Effect.succeed(true), + captureCheckpoint: () => Effect.succeed({ exclusions: [] }), hasCheckpointRef: () => Effect.succeed(true), restoreCheckpoint: () => Effect.succeed(true), diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => @@ -175,8 +175,8 @@ describe("CheckpointDiffQuery.layer", () => { }); const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, + supportsCheckpoints: () => Effect.succeed(true), + captureCheckpoint: () => Effect.succeed({ exclusions: [] }), hasCheckpointRef: () => Effect.succeed(true), restoreCheckpoint: () => Effect.succeed(true), diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => @@ -276,8 +276,8 @@ describe("CheckpointDiffQuery.layer", () => { }); const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, + supportsCheckpoints: () => Effect.succeed(true), + captureCheckpoint: () => Effect.succeed({ exclusions: [] }), hasCheckpointRef: () => Effect.succeed(true), restoreCheckpoint: () => Effect.succeed(true), diffCheckpoints: ({ ignoreWhitespace }) => @@ -357,8 +357,8 @@ describe("CheckpointDiffQuery.layer", () => { }); const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, + supportsCheckpoints: () => Effect.succeed(true), + captureCheckpoint: () => Effect.succeed({ exclusions: [] }), hasCheckpointRef: () => Effect.sync(() => { hasCheckpointRefCallCount += 1; @@ -427,8 +427,8 @@ describe("CheckpointDiffQuery.layer", () => { const threadId = ThreadId.make("thread-missing"); const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, + supportsCheckpoints: () => Effect.succeed(true), + captureCheckpoint: () => Effect.succeed({ exclusions: [] }), hasCheckpointRef: () => Effect.succeed(true), restoreCheckpoint: () => Effect.succeed(true), diffCheckpoints: () => Effect.succeed(""), diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 5a9b5cc5d6b0..407f7af7cee7 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -17,6 +17,7 @@ import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { createJjRepo, describeJj, runGit, runJj } from "../vcs/testing/JjTestSupport.ts"; import * as ServerConfig from "../config.ts"; const ServerConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { @@ -88,6 +89,21 @@ function initRepoWithCommit( }); } +/** `.t3code/vcs.json` is the supported way to make detection resolve one kind for a directory. */ +function pinVcsKind( + cwd: string, + kind: "git" | "jj", +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(NodePath.join(cwd, ".t3code"), { recursive: true }); + yield* fileSystem.writeFileString( + NodePath.join(cwd, ".t3code", "vcs.json"), + `{"vcs":{"kind":"${kind}"}}`, + ); + }); +} + function buildLargeText(lineCount = 5_000): string { return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) .join("\n") @@ -95,13 +111,13 @@ function buildLargeText(lineCount = 5_000): string { } it.layer(TestLayer)("CheckpointStore.layer", (it) => { - describe("isGitRepository", () => { - it.effect("returns false when no Git repository is detected", () => + describe("supportsCheckpoints", () => { + it.effect("returns false when no repository is detected", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const checkpointStore = yield* CheckpointStore.CheckpointStore; - expect(yield* checkpointStore.isGitRepository(tmp)).toBe(false); + expect(yield* checkpointStore.supportsCheckpoints(tmp)).toBe(false); }), ); @@ -111,11 +127,52 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { yield* initRepoWithCommit(tmp); const checkpointStore = yield* CheckpointStore.CheckpointStore; - expect(yield* checkpointStore.isGitRepository(tmp)).toBe(true); + expect(yield* checkpointStore.supportsCheckpoints(tmp)).toBe(true); }), ); + + describeJj("with Jujutsu installed", () => { + it.effect("returns true for a colocated Jujutsu repository", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* createJjRepo(tmp); + yield* pinVcsKind(tmp, "jj"); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + + expect(yield* checkpointStore.supportsCheckpoints(tmp)).toBe(true); + }), + ); + + it.effect("returns false for a Jujutsu repository with no colocated Git store", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const base = yield* makeTmpDir(); + const store = NodePath.join(base, "store.git"); + const root = NodePath.join(base, "project"); + yield* fileSystem.makeDirectory(store, { recursive: true }); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* runGit(store, ["init", "--bare", "--initial-branch=main", store]); + yield* runJj(root, ["git", "init", "--git-repo", store]); + yield* pinVcsKind(root, "jj"); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + + expect(yield* checkpointStore.supportsCheckpoints(root)).toBe(false); + }), + ); + }); }); + it.effect("detects a nested workspace without its own .git entry", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const fileSystem = yield* FileSystem.FileSystem; + const nested = NodePath.join(tmp, "packages", "nested"); + yield* fileSystem.makeDirectory(nested, { recursive: true }); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + expect(yield* checkpointStore.supportsCheckpoints(nested)).toBe(true); + }), + ); describe("diffCheckpoints", () => { it.effect("returns full oversized checkpoint diffs without truncation", () => Effect.gen(function* () { diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index 0c9e60d76a65..ce421007b74d 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -19,7 +19,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type { CheckpointStoreError } from "./Errors.ts"; -import type { VcsCheckpointOps } from "../vcs/VcsDriver.ts"; +import type { VcsCheckpointOps, CheckpointCaptureResult } from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; export interface CaptureCheckpointInput { @@ -51,8 +51,8 @@ export interface DeleteCheckpointRefsInput { export class CheckpointStore extends Context.Service< CheckpointStore, { - /** Check whether cwd is inside a Git worktree. */ - readonly isGitRepository: (cwd: string) => Effect.Effect; + /** Whether this workspace's VCS driver can capture and restore checkpoints here. */ + readonly supportsCheckpoints: (cwd: string) => Effect.Effect; /** * Capture a checkpoint commit and store it at the provided checkpoint ref. @@ -61,7 +61,7 @@ export class CheckpointStore extends Context.Service< */ readonly captureCheckpoint: ( input: CaptureCheckpointInput, - ) => Effect.Effect; + ) => Effect.Effect; /** Check whether a checkpoint ref exists. */ readonly hasCheckpointRef: ( @@ -117,10 +117,16 @@ export const make = Effect.gen(function* () { return handle.driver.checkpoints satisfies VcsCheckpointOps; }); - const isGitRepository: CheckpointStore["Service"]["isGitRepository"] = (cwd) => - vcsRegistry - .detect({ cwd, requestedKind: "git" }) - .pipe(Effect.map((repository) => repository !== null)); + const supportsCheckpoints: CheckpointStore["Service"]["supportsCheckpoints"] = Effect.fn( + "CheckpointStore.supportsCheckpoints", + )(function* (cwd: string) { + const handle = yield* vcsRegistry.detect({ cwd }); + if (!handle?.driver.checkpoints) { + return false; + } + const usable = handle.driver.checkpointsUsable; + return usable ? yield* usable(cwd) : true; + }); const captureCheckpoint: CheckpointStore["Service"]["captureCheckpoint"] = Effect.fn( "captureCheckpoint", @@ -161,7 +167,7 @@ export const make = Effect.gen(function* () { }); return CheckpointStore.of({ - isGitRepository, + supportsCheckpoints, captureCheckpoint, hasCheckpointRef, restoreCheckpoint, diff --git a/apps/server/src/git/ChangeRequestStep.test.ts b/apps/server/src/git/ChangeRequestStep.test.ts new file mode 100644 index 000000000000..0c568ff0ebdf --- /dev/null +++ b/apps/server/src/git/ChangeRequestStep.test.ts @@ -0,0 +1,137 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SourceControlProvider from "../sourceControl/SourceControlProvider.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import { resolveBranchHeadContext } from "./GitManager.ts"; +import * as Option from "effect/Option"; +import { + runChangeRequestStep, + type ChangeRequestVcsReads, + type SourceControlTextGenerationSettings, +} from "./GitManager.ts"; + +const settings: SourceControlTextGenerationSettings = { + modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "sonnet" }, + style: { + mode: "conventional_commits", + customInstructions: "", + followChangeRequestTemplates: false, + }, +}; + +const textGeneration: TextGeneration.TextGeneration["Service"] = { + generateCommitMessage: () => + Effect.fail( + new TextGenerationError({ operation: "generateCommitMessage", detail: "not exercised" }), + ), + generatePrContent: () => Effect.succeed({ title: "Add the thing", body: "Body" }), + generateBranchName: () => Effect.succeed({ branch: "feature/add-the-thing" }), + generateThreadTitle: () => Effect.succeed({ title: "Add the thing" }), +}; + +function makeLayer(created: Array<{ readonly baseRefName: string; readonly cwd: string }>) { + const providerLayer = Layer.mock(SourceControlProvider.SourceControlProvider)({ + kind: "github", + listChangeRequests: () => Effect.succeed([]), + getDefaultBranch: () => Effect.succeed("main"), + createChangeRequest: (input) => + Effect.sync(() => { + created.push({ baseRefName: input.baseRefName, cwd: input.cwd }); + }), + }); + + return Layer.mergeAll( + Layer.succeed(TextGeneration.TextGeneration, textGeneration), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + ServerSettings.layerTest(), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => undefined, + resolve: () => + SourceControlProvider.SourceControlProvider.pipe(Effect.provide(providerLayer)), + }), + NodeServices.layer, + ); +} + +/** The one read that differs between the lanes is where the hosting CLI runs. */ +function readsFor(providerCwd: string): ChangeRequestVcsReads { + return { + refName: "feat/thing", + hasUpstream: true, + upstreamRef: "origin/feat/thing", + readRangeContext: () => + Effect.succeed({ commitSummary: "abc feat", diffSummary: "1 file", diffPatch: "diff" }), + recentSubjects: () => Effect.succeed([]), + changeRequestTemplate: () => Effect.succeed(Option.none()), + providerCwd, + configuredBaseRef: () => Effect.succeed("release/v2"), + headContext: (cwd, refName, upstreamRef) => + resolveBranchHeadContext(() => Effect.succeed(null), cwd, { branch: refName, upstreamRef }), + resolvePrimaryRemoteName: () => Effect.succeed("origin"), + resolveDefaultRefName: () => Effect.succeed("main"), + resolveRemoteTrackingCommit: () => Effect.succeed({ commitSha: "deadbeef" }), + }; +} + +it.effect("opens the change request against the base the thread recorded, in both lanes", () => { + const created: Array<{ readonly baseRefName: string; readonly cwd: string }> = []; + + return Effect.gen(function* () { + const git = yield* runChangeRequestStep({ + settings, + cwd: "/repo", + fallbackRefName: null, + emit: () => Effect.void, + reads: readsFor("/repo"), + }); + + const jj = yield* runChangeRequestStep({ + settings, + cwd: "/repo/worktrees/thread", + fallbackRefName: null, + emit: () => Effect.void, + reads: readsFor("/repo"), + }); + + assert.equal(git.status, "created"); + assert.equal(jj.status, "created"); + assert.deepStrictEqual( + created.map((entry) => entry.baseRefName), + ["release/v2", "release/v2"], + ); + // Both lanes run the hosting CLI in the main workspace root. + assert.deepStrictEqual( + created.map((entry) => entry.cwd), + ["/repo", "/repo"], + ); + }).pipe(Effect.provide(makeLayer(created))); +}); + +it.effect("refuses to open a change request for a ref that was never pushed", () => { + const created: Array<{ readonly baseRefName: string; readonly cwd: string }> = []; + + return Effect.gen(function* () { + const failure = yield* runChangeRequestStep({ + settings, + cwd: "/repo", + fallbackRefName: null, + emit: () => Effect.void, + reads: { ...readsFor("/repo"), hasUpstream: false }, + }).pipe(Effect.flip); + + assert.include( + failure.message, + "Current branch has not been pushed. Push before creating a PR.", + ); + assert.equal(created.length, 0); + }).pipe(Effect.provide(makeLayer(created))); +}); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index e543ab758a67..b045b093bf43 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -30,17 +30,12 @@ import { type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, - type ProjectId, SourceControlProviderError, type SourceControlProviderKind, type SourceControlWritingStyleSettings, + type TextGenerationError, type ThreadId, } from "@t3tools/contracts"; -import { - hasProjectSettingsOverrides, - resolveProjectSettings, -} from "@t3tools/shared/projectSettings"; -import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, @@ -68,6 +63,7 @@ import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { workspaceSettings } from "../project/WorkspaceSettings.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; import type { ChangeRequest } from "@t3tools/contracts"; @@ -93,7 +89,7 @@ export type GitBranchPullRequest = NonNullable & { readonly mergedAt?: string | null; }; -interface SourceControlTextGenerationSettings { +export interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; readonly style: SourceControlWritingStyleSettings; } @@ -172,14 +168,14 @@ export function prLookupFailureTtl(consecutiveFailures: number): Duration.Durati return Duration.min(Duration.millis(backoffMs), PR_LOOKUP_FAILURE_MAX_TTL); } type StripProgressContext = T extends any ? Omit : never; -type GitActionProgressPayload = StripProgressContext; +export type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; function isNotGitRepositoryError(error: GitCommandError): boolean { return error.message.toLowerCase().includes("not a git repository"); } -interface OpenPrInfo { +export interface OpenPrInfo { number: number; title: string; url: string; @@ -187,7 +183,7 @@ interface OpenPrInfo { headRefName: string; } -interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { +export interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; isDraft?: boolean; closedAt?: string | null; @@ -200,7 +196,8 @@ const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInp (pullRequest) => pullRequest.updatedAt, ); -interface ResolvedPullRequest { +/** Shared with the Jujutsu lane, which resolves the same pull request against the same store. */ +export interface ResolvedPullRequest { number: number; title: string; url: string; @@ -209,13 +206,13 @@ interface ResolvedPullRequest { state: "open" | "closed" | "merged"; } -interface PullRequestHeadRemoteInfo { +export interface PullRequestHeadRemoteInfo { isCrossRepository?: boolean | undefined; headRepositoryNameWithOwner?: string | null | undefined; headRepositoryOwnerLogin?: string | null | undefined; } -interface BranchHeadContext { +export interface BranchHeadContext { localBranch: string; headBranch: string; headSelectors: ReadonlyArray; @@ -245,7 +242,7 @@ export function pullRequestRepositoryKey(value: string): string | null { } } -function parseRepositoryNameFromPullRequestUrl(url: string): string | null { +export function parseRepositoryNameFromPullRequestUrl(url: string): string | null { const trimmed = url.trim(); const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); const repositoryName = match?.[1]?.trim() ?? ""; @@ -273,7 +270,7 @@ function resolveHeadRepositoryNameWithOwner( return `${ownerLogin}/${repositoryName}`; } -function resolvePullRequestWorktreeLocalBranchName( +export function resolvePullRequestWorktreeLocalBranchName( pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, ): string { if (!pullRequest.isCrossRepository) { @@ -308,7 +305,7 @@ export function parseRepositoryNameWithOwnerFromRemoteUrl( return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; } -function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { +export function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { const trimmed = nameWithOwner?.trim() ?? ""; if (trimmed.length === 0) { return null; @@ -445,7 +442,7 @@ export function matchesBranchHeadContext( return true; } -function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { +export function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { return { number: summary.number, title: summary.title, @@ -469,7 +466,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { }; } -function limitContext(value: string, maxChars: number): string { +export function limitContext(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, maxChars)}\n\n[truncated]`; } @@ -493,7 +490,8 @@ function withDescription(title: string, description: string | undefined) { return description ? { title, description } : { title }; } -function summarizeGitActionResult( +/** Shared with the Jujutsu lane: the toast copy is about the action's result, not about Git. */ +export function summarizeGitActionResult( result: Pick, terms: ChangeRequestTerminology, ): { @@ -526,7 +524,7 @@ function summarizeGitActionResult( return { title: "Done" }; } -function sanitizeCommitMessage(generated: { +export function sanitizeCommitMessage(generated: { subject: string; body: string; branch?: string | undefined; @@ -556,20 +554,20 @@ function sanitizeProgressText(value: string): string | null { return trimmed.slice(0, MAX_PROGRESS_TEXT_LENGTH).trimEnd(); } -interface CommitAndBranchSuggestion { +export interface CommitAndBranchSuggestion { subject: string; body: string; branch?: string | undefined; commitMessage: string; } -function isCommitAction( +export function isCommitAction( action: GitStackedAction, ): action is "commit" | "commit_push" | "commit_push_pr" { return action === "commit" || action === "commit_push" || action === "commit_push_pr"; } -function formatCommitMessage(subject: string, body: string): string { +export function formatCommitMessage(subject: string, body: string): string { const trimmedBody = body.trim(); if (trimmedBody.length === 0) { return subject; @@ -615,7 +613,7 @@ function probeableHeadSelectors( : headSelectors; } -function appendUnique(values: string[], next: string | null | undefined): void { +export function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { return; @@ -648,13 +646,13 @@ function toStatusPr(pr: PullRequestInfo): { }; } -function normalizePullRequestReference(reference: string): string { +export function normalizePullRequestReference(reference: string): string { const trimmed = reference.trim(); const hashNumber = /^#(\d+)$/.exec(trimmed); return hashNumber?.[1] ?? trimmed; } -function toResolvedPullRequest(pr: { +export function toResolvedPullRequest(pr: { number: number; title: string; url: string; @@ -693,6 +691,608 @@ function toPullRequestHeadRemoteInfo(pr: { }; } +/** Every service the shared step needs; the caller discharges them from its own context. */ +export type ChangeRequestStepServices = + | Crypto.Crypto + | ServerSettings.ServerSettingsService + | FileSystem.FileSystem + | Path.Path + | ProviderRegistry.ProviderRegistry + | SourceControlProviderRegistry.SourceControlProviderRegistry + | TextGeneration.TextGeneration; + +/** The existing alias, `apps/server/src/sourceControl/PrTemplateDetection.ts:28`. */ +type ExecuteGit = GitVcsDriver.GitVcsDriver["Service"]["execute"]; + +/** A git config read that reports a missing key and a failed read alike as `null`. */ +type ReadConfigValue = (cwd: string, key: string) => Effect.Effect; + +const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; + +const readRepositoryInstructions = (cwd: string, fileName: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + 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(() => "")); + +export const readRecentCommitSubjects = (executeGit: ExecuteGit, cwd: string, revision?: string) => + executeGit({ + operation: "GitManager.readRecentCommitSubjects", + cwd, + args: ["log", "-n", "20", "--no-merges", "--pretty=format:%s", ...(revision ? [revision] : [])], + }).pipe( + Effect.map((result) => + result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + +export const resolveStylePolicy = ( + recentSubjects: (cwd: string) => Effect.Effect>, + 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 providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const subjects = yield* recentSubjects(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}`, + }; + } + } + }); + +export const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")( + function* (readConfigValue: ReadConfigValue, cwd: string, remoteName: string | null) { + if (!remoteName) { + return { + remoteUrlKey: null, + repositoryNameWithOwner: null, + ownerLogin: null, + }; + } + + const remoteUrl = yield* readConfigValue(cwd, `remote.${remoteName}.url`); + let repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); + if ( + remoteUrl !== null && + /^https?:\/\//iu.test(remoteUrl) && + (repositoryNameWithOwner?.split("/").length ?? 0) > 2 + ) { + const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const detected = detectSourceControlProviderFromGitRemoteUrl(remoteUrl); + const kind = + detected?.kind === "unknown" + ? yield* providers.resolve({ cwd }).pipe( + Effect.map((provider) => provider.kind), + Effect.orElseSucceed(() => undefined), + ) + : detected?.kind; + repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, kind); + } + return { + remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, + repositoryNameWithOwner, + ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; + }, +); + +export const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( + readConfigValue: ReadConfigValue, + cwd: string, + details: { branch: string; upstreamRef: string | null; remoteName?: string }, +) { + const remoteName = + details.remoteName ?? (yield* readConfigValue(cwd, `branch.${details.branch}.remote`)); + const headBranchFromUpstream = details.upstreamRef + ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) + : ""; + const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; + const shouldProbeLocalBranchSelector = + headBranchFromUpstream.length === 0 || headBranch === details.branch; + + const [remoteRepository, originRepository] = yield* Effect.all( + [ + resolveRemoteRepositoryContext(readConfigValue, cwd, remoteName), + resolveRemoteRepositoryContext(readConfigValue, cwd, "origin"), + ], + { concurrency: "unbounded" }, + ); + + const isCrossRepository = + remoteRepository.repositoryNameWithOwner !== null && + originRepository.repositoryNameWithOwner !== null + ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== + originRepository.repositoryNameWithOwner.toLowerCase() + : remoteName !== null && + remoteName !== "origin" && + remoteRepository.repositoryNameWithOwner !== null; + + const ownerHeadSelector = + remoteRepository.ownerLogin && headBranch.length > 0 + ? `${remoteRepository.ownerLogin}:${headBranch}` + : null; + const remoteAliasHeadSelector = + remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null; + const shouldProbeRemoteOwnedSelectors = + isCrossRepository || (remoteName !== null && remoteName !== "origin"); + + const headSelectors: string[] = []; + if (isCrossRepository && shouldProbeRemoteOwnedSelectors) { + appendUnique(headSelectors, ownerHeadSelector); + appendUnique( + headSelectors, + remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, + ); + } + if (shouldProbeLocalBranchSelector) { + appendUnique(headSelectors, details.branch); + } + appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); + if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { + appendUnique(headSelectors, ownerHeadSelector); + appendUnique( + headSelectors, + remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, + ); + } + + return { + localBranch: details.branch, + headBranch, + headSelectors, + preferredHeadSelector: ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, + remoteName, + headRemoteUrlKey: + remoteRepository.remoteUrlKey ?? (remoteName === null ? originRepository.remoteUrlKey : null), + targetRemoteUrlKey: originRepository.remoteUrlKey, + headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, + headRepositoryOwnerLogin: remoteRepository.ownerLogin, + isCrossRepository, + } satisfies BranchHeadContext; +}); + +export const findOpenPr = Effect.fn("findOpenPr")(function* ( + cwd: string, + headContext: Pick< + BranchHeadContext, + | "headBranch" + | "headSelectors" + | "headRepositoryNameWithOwner" + | "headRepositoryOwnerLogin" + | "isCrossRepository" + >, +) { + const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const provider = yield* providers.resolve({ cwd }); + for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) { + const pullRequests = yield* provider.listChangeRequests({ + cwd, + headSelector, + state: "open", + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1, + }); + const normalizedPullRequests = pullRequests.map(toPullRequestInfo); + + const firstPullRequest = normalizedPullRequests.find((pullRequest) => + matchesBranchHeadContext(pullRequest, headContext), + ); + if (firstPullRequest) { + return { + ...firstPullRequest, + state: "open", + updatedAt: Option.none(), + } satisfies PullRequestInfo; + } + } + + return null; +}); + +export interface ChangeRequestVcsReads { + /** Current ref/bookmark name; null means "no named line of work". */ + readonly refName: string | null; + readonly hasUpstream: boolean; + readonly upstreamRef: string | null; + readonly readRangeContext: ( + cwd: string, + baseRef: string, + ) => Effect.Effect; + readonly recentSubjects: (cwd: string) => Effect.Effect>; + readonly changeRequestTemplate: ( + cwd: string, + baseRevision: string, + ) => ReturnType; + /** Directory the hosting provider CLI runs in, the main workspace root under jj. */ + readonly providerCwd: string; + readonly configuredBaseRef: (cwd: string, refName: string) => Effect.Effect; + readonly headContext: ( + cwd: string, + refName: string, + upstreamRef: string | null, + ) => ReturnType; + readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; + readonly resolveDefaultRefName: ( + cwd: string, + remoteName: string, + ) => Effect.Effect; + readonly resolveRemoteTrackingCommit: (input: { + readonly cwd: string; + readonly refName: string; + readonly fallbackRemoteName: string; + }) => Effect.Effect<{ readonly commitSha: string }, GitCommandError>; +} + +const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( + reads: ChangeRequestVcsReads, + cwd: string, + branch: string, + upstreamRef: string | null, + headContext: Pick, +) { + const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const configured = yield* reads.configuredBaseRef(cwd, branch); + if (configured) return configured; + + if (upstreamRef && !headContext.isCrossRepository) { + const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { + remoteName: headContext.remoteName, + }); + if (upstreamBranch.length > 0 && upstreamBranch !== branch) { + return upstreamBranch; + } + } + + const defaultFromProvider = yield* providers.resolve({ cwd: reads.providerCwd }).pipe( + Effect.flatMap((provider) => provider.getDefaultBranch({ cwd: reads.providerCwd })), + Effect.orElseSucceed(() => null), + ); + if (defaultFromProvider) { + return defaultFromProvider; + } + + // The provider lookup can fail for reasons unrelated to the branch, so fall + // back to what the remote itself records before assuming a name. A repository + // whose default branch is master would otherwise get a base branch that does + // not exist. + const defaultFromRemote = yield* reads.resolvePrimaryRemoteName(cwd).pipe( + Effect.flatMap((remoteName) => reads.resolveDefaultRefName(cwd, remoteName)), + Effect.orElseSucceed(() => null), + ); + if (defaultFromRemote) { + return defaultFromRemote; + } + + return "main"; +}); + +const resolveBaseRangeRef = Effect.fn("resolveBaseRangeRef")(function* ( + reads: ChangeRequestVcsReads, + cwd: string, + baseBranch: string, +) { + const remoteName = yield* reads + .resolvePrimaryRemoteName(cwd) + .pipe(Effect.orElseSucceed(() => null)); + if (!remoteName) return baseBranch; + + return yield* reads + .resolveRemoteTrackingCommit({ + cwd, + refName: baseBranch, + fallbackRemoteName: remoteName, + }) + .pipe( + Effect.map((resolved) => resolved.commitSha), + Effect.orElseSucceed(() => baseBranch), + ); +}); + +/** + * The commit message (and, for a feature-branch action, the branch name) a stacked action commits + * with. Only the working-copy context read is VCS-specific, so it is the one thing passed in. + */ +export const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( + function* (input: { + readonly cwd: string; + readonly branch: string | null; + readonly commitMessage?: string; + /** When true, also produce a semantic feature branch name. */ + readonly includeBranch?: boolean; + readonly filePaths?: readonly string[]; + readonly settings: SourceControlTextGenerationSettings; + readonly recentSubjects: (cwd: string) => Effect.Effect>; + readonly prepareCommitContext: ( + cwd: string, + filePaths?: readonly string[], + ) => Effect.Effect; + }): Effect.fn.Return< + CommitAndBranchSuggestion | null, + GitCommandError | TextGenerationError, + ChangeRequestStepServices + > { + const textGeneration = yield* TextGeneration.TextGeneration; + const context = yield* input.prepareCommitContext(input.cwd, input.filePaths); + if (!context) { + return null; + } + + const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); + if (customCommit) { + return { + subject: customCommit.subject, + body: customCommit.body, + ...(input.includeBranch ? { branch: sanitizeFeatureBranchName(customCommit.subject) } : {}), + commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), + }; + } + + const policy = yield* resolveStylePolicy(input.recentSubjects, input.cwd, input.settings); + + const generated = yield* textGeneration + .generateCommitMessage({ + cwd: input.cwd, + branch: input.branch, + stagedSummary: limitContext(context.stagedSummary, 8_000), + stagedPatch: limitContext(context.stagedPatch, 50_000), + ...(input.includeBranch ? { includeBranch: true } : {}), + ...(policy ? { policy } : {}), + modelSelection: input.settings.modelSelection, + }) + .pipe(Effect.map((result) => sanitizeCommitMessage(result))); + + return { + subject: generated.subject, + body: generated.body, + ...(generated.branch !== undefined ? { branch: generated.branch } : {}), + commitMessage: formatCommitMessage(generated.subject, generated.body), + }; + }, +); + +/** + * Mirrors the former private `runPrStep(settings, cwd, fallbackBranch, emit)` with the VCS reads + * lifted into `reads`, so the Jujutsu lane runs the same step against the colocated Git store. + */ +export const runChangeRequestStep = Effect.fn("runChangeRequestStep")(function* (input: { + readonly settings: SourceControlTextGenerationSettings; + readonly cwd: string; + /** Used when `reads.refName` is null, exactly as `details.branch ?? fallbackBranch` does. */ + readonly fallbackRefName: string | null; + readonly emit: GitActionProgressEmitter; + readonly reads: ChangeRequestVcsReads; +}): Effect.fn.Return< + GitRunStackedActionResult["pr"], + GitManagerServiceError, + | SourceControlProviderRegistry.SourceControlProviderRegistry + | TextGeneration.TextGeneration + | ProviderRegistry.ProviderRegistry + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto +> { + const { cwd, emit, reads, settings } = input; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const textGeneration = yield* TextGeneration.TextGeneration; + + const provider = yield* providers.resolve({ cwd: reads.providerCwd }); + const terms = getChangeRequestTerminologyForKind(provider.kind); + const branch = reads.refName ?? input.fallbackRefName; + if (!branch) { + return yield* new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Name a line of work before creating a pull request.", + }); + } + if (!reads.hasUpstream) { + return yield* new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Current branch has not been pushed. Push before creating a PR.", + }); + } + + const headContext = yield* reads.headContext(cwd, branch, reads.upstreamRef); + + const existing = yield* findOpenPr(reads.providerCwd, headContext); + if (existing) { + return { + status: "opened_existing" as const, + url: existing.url, + number: existing.number, + baseBranch: existing.baseRefName, + headBranch: existing.headRefName, + title: existing.title, + }; + } + + const baseBranch = yield* resolveBaseBranch(reads, cwd, branch, reads.upstreamRef, headContext); + yield* emit({ + kind: "phase_started", + phase: "pr", + label: `Generating ${terms.shortLabel} content...`, + }); + const baseRangeRef = yield* resolveBaseRangeRef(reads, cwd, baseBranch); + const rangeContext = yield* reads.readRangeContext(cwd, baseRangeRef); + const policy = yield* resolveStylePolicy(reads.recentSubjects, cwd, settings); + const changeRequestTemplate = + settings.style.followChangeRequestTemplates && provider.kind === "github" + ? Option.getOrUndefined(yield* reads.changeRequestTemplate(cwd, baseRangeRef)) + : undefined; + + const generated = yield* textGeneration.generatePrContent({ + cwd, + baseBranch, + headBranch: headContext.headBranch, + commitSummary: limitContext(rangeContext.commitSummary, 20_000), + diffSummary: limitContext(rangeContext.diffSummary, 20_000), + diffPatch: limitContext(rangeContext.diffPatch, 60_000), + ...(changeRequestTemplate ? { changeRequestTemplate } : {}), + ...(policy ? { policy } : {}), + modelSelection: settings.modelSelection, + }); + + const uuid = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "randomUUIDv4", + cwd, + detail: "Failed to generate Git operation identifier.", + cause, + }), + ), + ); + const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${uuid}.md`); + yield* fileSystem.writeFileString(bodyFile, generated.body).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Failed to write pull request body temp file.", + cause, + }), + ), + ); + yield* emit({ + kind: "phase_started", + phase: "pr", + label: `Creating ${terms.singular}...`, + }); + yield* provider + .createChangeRequest({ + cwd: reads.providerCwd, + baseRefName: baseBranch, + headSelector: headContext.preferredHeadSelector, + title: generated.title, + bodyFile, + }) + .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.ignore))); + + const created = yield* findOpenPr(reads.providerCwd, headContext); + if (!created) { + return { + status: "created" as const, + baseBranch, + headBranch: headContext.headBranch, + title: generated.title, + }; + } + + return { + status: "created" as const, + url: created.url, + number: created.number, + baseBranch: created.baseRefName, + headBranch: created.headRefName, + title: created.title, + }; +}); + +/** + * Environment settings with the acting project's overrides applied, resolved to the writer model + * and style both the commit and the change-request phases generate with. + */ +export const resolveTextGenerationSettings = Effect.fn("resolveTextGenerationSettings")( + function* (input: { + readonly operation: string; + readonly cwd: string; + readonly threadId?: ThreadId | undefined; + }): Effect.fn.Return< + SourceControlTextGenerationSettings, + GitManagerError, + ServerSettings.ServerSettingsService | ProviderRegistry.ProviderRegistry + > { + const serverSettingsService = yield* ServerSettings.ServerSettingsService; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + return yield* Effect.gen(function* () { + const projectSettings = yield* workspaceSettings(input).pipe( + Effect.provideService(ServerSettings.ServerSettingsService, serverSettingsService), + ); + + if (projectSettings.sourceControlWriterModelSelection === null) { + return { + modelSelection: projectSettings.textGenerationModelSelection, + style: projectSettings.sourceControlWritingStyle, + }; + } + const providers = yield* providerRegistry.getProviders; + return { + modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( + projectSettings, + providers, + ), + style: projectSettings.sourceControlWritingStyle, + }; + }).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: input.operation, + cwd: input.cwd, + detail: "Failed to get server settings.", + cause, + }), + ), + ); + }, +); + export const make = Effect.gen(function* () { const gitCore = yield* GitVcsDriver.GitVcsDriver; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; @@ -704,105 +1304,45 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); + + /** The shared remote-context helpers read the provider registry from context; discharge it once. */ + const withSourceControlProviders = ( + effect: Effect.Effect, + ): Effect.Effect => + Effect.provideService( + effect, + SourceControlProviderRegistry.SourceControlProviderRegistry, + sourceControlProviders, + ); + + /** + * The shared change-request step takes its services from context; every method on this service + * is declared with an empty requirement channel, so they are discharged here, once. + */ + const withChangeRequestServices = ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ServerSettings.ServerSettingsService, serverSettingsService), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ProviderRegistry.ProviderRegistry, providerRegistry), + Effect.provideService( + SourceControlProviderRegistry.SourceControlProviderRegistry, + sourceControlProviders, + ), + Effect.provideService(TextGeneration.TextGeneration, textGeneration), + ); const serverSettingsService = yield* ServerSettings.ServerSettingsService; - // Optional: git actions also run from the CLI and tests without orchestration. - const projectionQuery = yield* Effect.serviceOption( - ProjectionSnapshotQuery.ProjectionSnapshotQuery, - ); - /** Environment settings with the acting project's overrides applied. */ const projectSettingsFor = Effect.fnUntraced(function* (input: { readonly cwd: string; readonly threadId?: ThreadId | undefined; }) { - const settings = yield* serverSettingsService.getSettings; - if (!hasProjectSettingsOverrides(settings) || Option.isNone(projectionQuery)) return settings; - const projectId = yield* ( - input.threadId !== undefined - ? projectionQuery.value - .getThreadShellById(input.threadId) - .pipe(Effect.map(Option.map((thread) => thread.projectId))) - : projectionQuery.value - .getActiveProjectByWorkspaceRoot(input.cwd) - .pipe(Effect.map(Option.map((project) => project.id))) - ).pipe(Effect.orElseSucceed(() => Option.none())); - return resolveProjectSettings(settings, Option.getOrNull(projectId)).settings; + return yield* workspaceSettings(input).pipe( + Effect.provideService(ServerSettings.ServerSettingsService, serverSettingsService), + ); }); - 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 randomUUIDv4 = (cwd: string) => crypto.randomUUIDv4.pipe( Effect.mapError( @@ -996,7 +1536,6 @@ 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; @@ -1341,52 +1880,18 @@ export const make = Effect.gen(function* () { return handle?.context?.provider ?? provider; }); - const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( - cwd: string, - remoteName: string | null, - ) { - if (!remoteName) { - return { - remoteUrlKey: null, - repositoryNameWithOwner: null, - ownerLogin: null, - }; - } - - const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); - let repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); - if ( - remoteUrl !== null && - /^https?:\/\//iu.test(remoteUrl) && - (repositoryNameWithOwner?.split("/").length ?? 0) > 2 - ) { - const detected = detectSourceControlProviderFromGitRemoteUrl(remoteUrl); - const kind = - detected?.kind === "unknown" - ? yield* sourceControlProvider(cwd).pipe( - Effect.map((provider) => provider.kind), - Effect.orElseSucceed(() => undefined), - ) - : detected?.kind; - repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, kind); - } - return { - remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, - repositoryNameWithOwner, - ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), - }; - }); - const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( function* (cwd: string, branch: string, remoteNameOverride?: string) { const remoteName = remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); - const [headRemote, targetRemote] = yield* Effect.all( - [ - resolveRemoteRepositoryContext(cwd, remoteName), - resolveRemoteRepositoryContext(cwd, "origin"), - ], - { concurrency: "unbounded" }, + const [headRemote, targetRemote] = yield* withSourceControlProviders( + Effect.all( + [ + resolveRemoteRepositoryContext(readConfigValueNullable, cwd, remoteName), + resolveRemoteRepositoryContext(readConfigValueNullable, cwd, "origin"), + ], + { concurrency: "unbounded" }, + ), ); return { remoteName, @@ -1397,83 +1902,6 @@ export const make = Effect.gen(function* () { }, ); - const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( - cwd: string, - details: { branch: string; upstreamRef: string | null; remoteName?: string }, - ) { - const remoteName = - details.remoteName ?? - (yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`)); - const headBranchFromUpstream = details.upstreamRef - ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) - : ""; - const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; - const shouldProbeLocalBranchSelector = - headBranchFromUpstream.length === 0 || headBranch === details.branch; - - const [remoteRepository, originRepository] = yield* Effect.all( - [ - resolveRemoteRepositoryContext(cwd, remoteName), - resolveRemoteRepositoryContext(cwd, "origin"), - ], - { concurrency: "unbounded" }, - ); - - const isCrossRepository = - remoteRepository.repositoryNameWithOwner !== null && - originRepository.repositoryNameWithOwner !== null - ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== - originRepository.repositoryNameWithOwner.toLowerCase() - : remoteName !== null && - remoteName !== "origin" && - remoteRepository.repositoryNameWithOwner !== null; - - const ownerHeadSelector = - remoteRepository.ownerLogin && headBranch.length > 0 - ? `${remoteRepository.ownerLogin}:${headBranch}` - : null; - const remoteAliasHeadSelector = - remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null; - const shouldProbeRemoteOwnedSelectors = - isCrossRepository || (remoteName !== null && remoteName !== "origin"); - - const headSelectors: string[] = []; - if (isCrossRepository && shouldProbeRemoteOwnedSelectors) { - appendUnique(headSelectors, ownerHeadSelector); - appendUnique( - headSelectors, - remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, - ); - } - if (shouldProbeLocalBranchSelector) { - appendUnique(headSelectors, details.branch); - } - appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); - if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { - appendUnique(headSelectors, ownerHeadSelector); - appendUnique( - headSelectors, - remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, - ); - } - - return { - localBranch: details.branch, - headBranch, - headSelectors, - preferredHeadSelector: - ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, - remoteName, - headRemoteUrlKey: - remoteRepository.remoteUrlKey ?? - (remoteName === null ? originRepository.remoteUrlKey : null), - targetRemoteUrlKey: originRepository.remoteUrlKey, - headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, - headRepositoryOwnerLogin: remoteRepository.ownerLogin, - isCrossRepository, - } satisfies BranchHeadContext; - }); - // The remote that holds a ref named after the local branch, or null when // none does. Remote names may contain slashes, so refs are matched literally // per remote instead of with a glob. When several remotes hold the name, the @@ -1540,7 +1968,9 @@ export const make = Effect.gen(function* () { remoteName?: string; }, ) { - const headContext = yield* resolveBranchHeadContext(cwd, details); + const headContext = yield* withSourceControlProviders( + resolveBranchHeadContext(readConfigValueNullable, cwd, details), + ); const upstreamHeadIsDefault = headContext.headBranch === details.defaultBranch || (details.defaultBranch === null && @@ -1556,11 +1986,13 @@ export const make = Effect.gen(function* () { if (remoteName === null) { return { headContext, lookup: false }; } - const ownNameContext = yield* resolveBranchHeadContext(cwd, { - branch: details.branch, - upstreamRef: null, - remoteName, - }); + const ownNameContext = yield* withSourceControlProviders( + resolveBranchHeadContext(readConfigValueNullable, cwd, { + branch: details.branch, + upstreamRef: null, + remoteName, + }), + ); return { headContext: ownNameContext, lookup: true }; }); @@ -1614,43 +2046,6 @@ export const make = Effect.gen(function* () { }).pipe(Effect.orElseSucceed(() => false)); }); - const findOpenPr = Effect.fn("findOpenPr")(function* ( - cwd: string, - headContext: Pick< - BranchHeadContext, - | "headBranch" - | "headSelectors" - | "headRepositoryNameWithOwner" - | "headRepositoryOwnerLogin" - | "isCrossRepository" - >, - ) { - const provider = yield* sourceControlProvider(cwd); - const headSelectors = probeableHeadSelectors(provider.kind, headContext.headSelectors); - for (const headSelector of headSelectors) { - const pullRequests = yield* provider.listChangeRequests({ - cwd, - headSelector, - state: "open", - limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1, - }); - const normalizedPullRequests = pullRequests.map(toPullRequestInfo); - - const firstPullRequest = normalizedPullRequests.find((pullRequest) => - matchesBranchHeadContext(pullRequest, headContext), - ); - if (firstPullRequest) { - return { - ...firstPullRequest, - state: "open", - updatedAt: Option.none(), - } satisfies PullRequestInfo; - } - } - - return null; - }); - const findLatestPrForHeadContext = Effect.fn("findLatestPrForHeadContext")(function* ( cwd: string, headContext: BranchHeadContext, @@ -1727,12 +2122,14 @@ export const make = Effect.gen(function* () { finalBranchContext?.hasUpstream === true; if (shouldLookupExistingOpenPr && finalBranchContext) { - latestOpenPr = yield* resolveBranchHeadContext(cwd, { - branch: finalBranchContext.branch, - upstreamRef: finalBranchContext.upstreamRef, - }).pipe( - Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), - Effect.orElseSucceed(() => null), + latestOpenPr = yield* withSourceControlProviders( + resolveBranchHeadContext(readConfigValueNullable, cwd, { + branch: finalBranchContext.branch, + upstreamRef: finalBranchContext.upstreamRef, + }).pipe( + Effect.flatMap((headContext) => withChangeRequestServices(findOpenPr(cwd, headContext))), + Effect.orElseSucceed(() => null), + ), ); } @@ -1776,118 +2173,33 @@ export const make = Effect.gen(function* () { }; }); - const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( - cwd: string, - branch: string, - upstreamRef: string | null, - headContext: Pick, - ) { - const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); - if (configured) return configured; - - if (upstreamRef && !headContext.isCrossRepository) { - const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { - remoteName: headContext.remoteName, - }); - if (upstreamBranch.length > 0 && upstreamBranch !== branch) { - return upstreamBranch; - } - } - - const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( - Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), - Effect.orElseSucceed(() => null), - ); - if (defaultFromProvider) { - return defaultFromProvider; - } - - // The provider lookup can fail for reasons unrelated to the branch, so fall - // back to what the remote itself records before assuming a name. A repository - // whose default branch is master would otherwise get a base branch that does - // not exist. - const defaultFromRemote = yield* gitCore.resolvePrimaryRemoteName(cwd).pipe( - Effect.flatMap((remoteName) => gitCore.resolveDefaultBranchName(cwd, remoteName)), - Effect.orElseSucceed(() => null), - ); - if (defaultFromRemote) { - return defaultFromRemote; - } - - return "main"; - }); - - const resolveBaseRangeRef = Effect.fn("resolveBaseRangeRef")(function* ( + /** Git's half of the shared change-request step: every read comes from `GitVcsDriver`. */ + const gitChangeRequestReads = Effect.fn("GitManager.gitChangeRequestReads")(function* ( cwd: string, - baseBranch: string, ) { - const remoteName = yield* gitCore - .resolvePrimaryRemoteName(cwd) - .pipe(Effect.orElseSucceed(() => null)); - if (!remoteName) return baseBranch; - - return yield* gitCore - .resolveRemoteTrackingCommit({ - cwd, - refName: baseBranch, - fallbackRemoteName: remoteName, - }) - .pipe( - Effect.map((resolved) => resolved.commitSha), - Effect.orElseSucceed(() => baseBranch), - ); + const details = yield* gitCore.statusDetails(cwd); + return { + refName: details.branch, + hasUpstream: details.hasUpstream, + upstreamRef: details.upstreamRef, + readRangeContext: gitCore.readRangeContext, + recentSubjects: (historyCwd: string) => readRecentCommitSubjects(gitCore.execute, historyCwd), + changeRequestTemplate: (templateCwd: string, baseRevision: string) => + detectPrTemplate(templateCwd, baseRevision, gitCore.execute), + providerCwd: cwd, + configuredBaseRef: (configCwd: string, refName: string) => + readConfigValueNullable(configCwd, `branch.${refName}.gh-merge-base`), + headContext: (configCwd: string, refName: string, upstreamRef: string | null) => + resolveBranchHeadContext(readConfigValueNullable, configCwd, { + branch: refName, + upstreamRef, + }), + resolvePrimaryRemoteName: gitCore.resolvePrimaryRemoteName, + resolveDefaultRefName: gitCore.resolveDefaultBranchName, + resolveRemoteTrackingCommit: gitCore.resolveRemoteTrackingCommit, + } satisfies ChangeRequestVcsReads; }); - const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( - function* (input: { - cwd: string; - branch: string | null; - commitMessage?: string; - /** When true, also produce a semantic feature branch name. */ - includeBranch?: boolean; - filePaths?: readonly string[]; - settings: SourceControlTextGenerationSettings; - }) { - const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); - if (!context) { - return null; - } - - const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); - if (customCommit) { - return { - subject: customCommit.subject, - body: customCommit.body, - ...(input.includeBranch - ? { branch: sanitizeFeatureBranchName(customCommit.subject) } - : {}), - commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), - }; - } - - const policy = yield* resolveStylePolicy(input.cwd, input.settings); - - const generated = yield* textGeneration - .generateCommitMessage({ - cwd: input.cwd, - branch: input.branch, - stagedSummary: limitContext(context.stagedSummary, 8_000), - stagedPatch: limitContext(context.stagedPatch, 50_000), - ...(input.includeBranch ? { includeBranch: true } : {}), - ...(policy ? { policy } : {}), - modelSelection: input.settings.modelSelection, - }) - .pipe(Effect.map((result) => sanitizeCommitMessage(result))); - - return { - subject: generated.subject, - body: generated.body, - ...(generated.branch !== undefined ? { branch: generated.branch } : {}), - commitMessage: formatCommitMessage(generated.subject, generated.body), - }; - }, - ); - const runCommitStep = Effect.fn("runCommitStep")(function* ( settings: SourceControlTextGenerationSettings, cwd: string, @@ -1919,13 +2231,18 @@ export const make = Effect.gen(function* () { label: "Generating commit message...", }); } - suggestion = yield* resolveCommitAndBranchSuggestion({ - cwd, - branch, - ...(commitMessage ? { commitMessage } : {}), - ...(filePaths ? { filePaths } : {}), - settings, - }); + suggestion = yield* withChangeRequestServices( + resolveCommitAndBranchSuggestion({ + cwd, + branch, + ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), + settings, + recentSubjects: (historyCwd: string) => + readRecentCommitSubjects(gitCore.execute, historyCwd), + prepareCommitContext: gitCore.prepareCommitContext, + }), + ); } if (!suggestion) { return { status: "skipped_no_changes" as const }; @@ -2001,124 +2318,6 @@ export const make = Effect.gen(function* () { }; }); - const runPrStep = Effect.fn("runPrStep")(function* ( - settings: SourceControlTextGenerationSettings, - cwd: string, - fallbackBranch: string | null, - emit: GitActionProgressEmitter, - ) { - const provider = yield* sourceControlProvider(cwd); - const terms = getChangeRequestTerminologyForKind(provider.kind); - const details = yield* gitCore.statusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - return yield* new GitManagerError({ - operation: "runPrStep", - cwd, - detail: "Cannot create a pull request from detached HEAD.", - }); - } - if (!details.hasUpstream) { - return yield* new GitManagerError({ - operation: "runPrStep", - cwd, - detail: "Current branch has not been pushed. Push before creating a PR.", - }); - } - - const headContext = yield* resolveBranchHeadContext(cwd, { - branch, - upstreamRef: details.upstreamRef, - }); - - const existing = yield* findOpenPr(cwd, headContext); - if (existing) { - return { - status: "opened_existing" as const, - url: existing.url, - number: existing.number, - baseBranch: existing.baseRefName, - headBranch: existing.headRefName, - title: existing.title, - }; - } - - const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); - yield* emit({ - kind: "phase_started", - phase: "pr", - label: `Generating ${terms.shortLabel} content...`, - }); - const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch); - const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); - const policy = yield* resolveStylePolicy(cwd, settings); - const changeRequestTemplate = - settings.style.followChangeRequestTemplates && provider.kind === "github" - ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) - : undefined; - - const generated = yield* textGeneration.generatePrContent({ - cwd, - baseBranch, - headBranch: headContext.headBranch, - commitSummary: limitContext(rangeContext.commitSummary, 20_000), - diffSummary: limitContext(rangeContext.diffSummary, 20_000), - diffPatch: limitContext(rangeContext.diffPatch, 60_000), - ...(changeRequestTemplate ? { changeRequestTemplate } : {}), - ...(policy ? { policy } : {}), - modelSelection: settings.modelSelection, - }); - - const bodyFile = path.join( - tempDir, - `t3code-pr-body-${process.pid}-${yield* randomUUIDv4(cwd)}.md`, - ); - yield* fileSystem.writeFileString(bodyFile, generated.body).pipe( - Effect.mapError( - (cause) => - new GitManagerError({ - operation: "runPrStep", - cwd, - detail: "Failed to write pull request body temp file.", - cause, - }), - ), - ); - yield* emit({ - kind: "phase_started", - phase: "pr", - label: `Creating ${terms.singular}...`, - }); - yield* provider - .createChangeRequest({ - cwd, - baseRefName: baseBranch, - headSelector: headContext.preferredHeadSelector, - title: generated.title, - bodyFile, - }) - .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.ignore))); - - const created = yield* findOpenPr(cwd, headContext); - if (!created) { - return { - status: "created" as const, - baseBranch, - headBranch: headContext.headBranch, - title: generated.title, - }; - } - - return { - status: "created" as const, - url: created.url, - number: created.number, - baseBranch: created.baseRefName, - headBranch: created.headRefName, - title: created.title, - }; - }); - const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")( function* (input) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); @@ -2614,14 +2813,19 @@ export const make = Effect.gen(function* () { commitMessage?: string, filePaths?: readonly string[], ) { - const suggestion = yield* resolveCommitAndBranchSuggestion({ - cwd, - branch, - ...(commitMessage ? { commitMessage } : {}), - ...(filePaths ? { filePaths } : {}), - includeBranch: true, - settings, - }); + const suggestion = yield* withChangeRequestServices( + resolveCommitAndBranchSuggestion({ + cwd, + branch, + ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), + includeBranch: true, + settings, + recentSubjects: (historyCwd: string) => + readRecentCommitSubjects(gitCore.execute, historyCwd), + prepareCommitContext: gitCore.prepareCommitContext, + }), + ); if (!suggestion) { return yield* new GitManagerError({ operation: "runFeatureBranchStep", @@ -2709,32 +2913,12 @@ 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, - })), - ), - ), - Effect.mapError( - (cause) => - new GitManagerError({ - operation: "runStackedAction", - cwd: input.cwd, - detail: "Failed to get server settings.", - cause, - }), - ), + const textGenerationSettings = yield* withChangeRequestServices( + resolveTextGenerationSettings({ + operation: "runStackedAction", + cwd: input.cwd, + ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), + }), ); if (input.featureBranch) { @@ -2807,8 +2991,17 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), - Effect.flatMap(() => - runPrStep(textGenerationSettings, input.cwd, currentBranch, progress.emit), + Effect.flatMap(() => gitChangeRequestReads(input.cwd)), + Effect.flatMap((reads) => + withChangeRequestServices( + runChangeRequestStep({ + settings: textGenerationSettings, + cwd: input.cwd, + fallbackRefName: currentBranch, + emit: progress.emit, + reads, + }), + ), ), ) : { status: "skipped_not_requested" as const }; diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 837da2f4aa9f..70444c76737d 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,39 +1,52 @@ import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsRepositoryDetectionError } from "@t3tools/contracts"; -import * as GitManager from "./GitManager.ts"; -import * as GitWorkflowService from "./GitWorkflowService.ts"; +import * as JjWorkflow from "../jj/JjWorkflow.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import { ServerSettingsService } from "../serverSettings.ts"; +import * as GitManager from "./GitManager.ts"; +import * as GitWorkflowService from "./GitWorkflowService.ts"; + +/** Only `kind` reaches the routing table; the rest of the handle never does. */ +function handleFor(kind: "git" | "jj"): VcsDriverRegistry.VcsDriverHandle { + return { kind } as unknown as VcsDriverRegistry.VcsDriverHandle; +} function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; + readonly git?: Partial; + readonly gitManager?: Partial; + readonly jj?: Partial; }) { return GitWorkflowService.layer.pipe( + Layer.provide(NodeServices.layer), Layer.provide(ServerSettingsService.layerTest()), Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ detect: input.detect, }), ), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), - Layer.provide(Layer.mock(GitManager.GitManager)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(input.git ?? {})), + Layer.provide(Layer.mock(GitManager.GitManager)(input.gitManager ?? {})), + Layer.provide(Layer.mock(JjWorkflow.JjWorkflow)(input.jj ?? {})), ); } describe("GitWorkflowService", () => { - it.effect("reports a non-Git VCS repository as not a Git repository", () => + it.effect("recognizes a Jujutsu repository for workspace bootstrap", () => Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; const isRepository = yield* workflow.isRepository("/jj-repo"); - assert.equal(isRepository, false); + assert.equal(isRepository, true); }).pipe( Effect.provide( makeLayer({ @@ -120,6 +133,7 @@ describe("GitWorkflowService", () => { const status = vi.fn(); const testLayer = GitWorkflowService.layer.pipe( + Layer.provide(NodeServices.layer), Layer.provide(ServerSettingsService.layerTest()), Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ @@ -127,6 +141,7 @@ describe("GitWorkflowService", () => { }), ), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(JjWorkflow.JjWorkflow)({})), Layer.provide( Layer.mock(GitManager.GitManager)({ localStatus, @@ -169,24 +184,25 @@ describe("GitWorkflowService", () => { ), ); - it.effect("structures workflow detection failures without exposing upstream details", () => { + it.effect("fails the status poll when detection itself fails, keeping the cause", () => { const cause = new VcsRepositoryDetectionError({ operation: "VcsDriverRegistry.detect", cwd: "/repo", - detail: "upstream detail must stay in the cause chain", + detail: "the reason a healthy project could not be classified", }); return Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; const error = yield* workflow.status({ cwd: "/repo" }).pipe(Effect.flip); + // Reporting `isRepo: false` here renders a blank branch toolbar for a working repository + // and drops the only record of why detection failed. expect(error).toMatchObject({ _tag: "GitManagerError", operation: "GitWorkflowService.status", cwd: "/repo", - detail: "Failed to detect a VCS repository for this Git workflow.", + cause, }); - expect(error.message).not.toContain(cause.detail); }).pipe( Effect.provide( makeLayer({ @@ -196,31 +212,187 @@ describe("GitWorkflowService", () => { ); }); - it.effect("structures command detection failures without exposing upstream details", () => { - const cause = new VcsRepositoryDetectionError({ - operation: "VcsDriverRegistry.detect", - cwd: "/repo", - detail: "upstream command detail must stay in the cause chain", - }); - - return Effect.gen(function* () { + it.effect("names the missing repository when detection reports none", () => + Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; - const error = yield* workflow.listRefs({ cwd: "/repo" }).pipe(Effect.flip); + const error = yield* workflow.createRef({ cwd: "/repo", refName: "feat" }).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "GitCommandError", - operation: "GitWorkflowService.listRefs", + operation: "GitWorkflowService.createRef", command: "vcs-route", cwd: "/repo", - detail: "Failed to detect a VCS repository for this Git command.", }); - expect(error.message).not.toContain(cause.detail); + expect(error.detail).toContain("found no version control repository here"); }).pipe( Effect.provide( makeLayer({ - detect: () => Effect.fail(cause), + detect: () => Effect.succeed(null), + }), + ), + ), + ); + + it.effect( + "separates an undetectable directory from a missing one, without leaking the cause", + () => { + const cause = new VcsRepositoryDetectionError({ + operation: "VcsDriverRegistry.detect", + cwd: "/repo", + detail: "upstream command detail must stay in the cause chain", + }); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow + .createRef({ cwd: "/repo", refName: "feat" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitCommandError", + operation: "GitWorkflowService.createRef", + command: "vcs-route", + cwd: "/repo", + cause, + }); + expect(error.detail).not.toContain("found no version control repository here"); + expect(error.message).not.toContain(cause.detail); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.fail(cause), + }), + ), + ); + }, + ); + + it.effect("routes a Git cwd to the Git lane and a Jujutsu cwd to JjWorkflow", () => { + const gitListRefs = vi.fn(() => + Effect.succeed({ + refs: [], + isRepo: true, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }), + ); + const jjListRefs = vi.fn(() => + Effect.succeed({ + refs: [], + isRepo: true, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }), + ); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + yield* workflow.listRefs({ cwd: "/git-repo" }); + yield* workflow.listRefs({ cwd: "/jj-repo" }); + + assert.equal(gitListRefs.mock.calls.length, 1); + assert.equal(jjListRefs.mock.calls.length, 1); + }).pipe( + Effect.provide( + makeLayer({ + detect: ({ cwd }) => Effect.succeed(handleFor(cwd === "/jj-repo" ? "jj" : "git")), + git: { listRefs: gitListRefs }, + jj: { listRefs: jjListRefs }, + }), + ), + ); + }); + + it.effect("drops both lanes' caches on every invalidation", () => { + const gitInvalidate = vi.fn(() => Effect.void); + const jjInvalidate = vi.fn(() => Effect.void); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + yield* workflow.invalidateStatus("/repo"); + + assert.equal(gitInvalidate.mock.calls.length, 1); + assert.equal(jjInvalidate.mock.calls.length, 1); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(handleFor("git")), + gitManager: { invalidateStatus: gitInvalidate }, + jj: { invalidateStatus: jjInvalidate }, + }), + ), + ); + }); + + it.effect("removes a pre-flip Git worktree inside a Jujutsu project through the Git lane", () => { + const gitRemoveWorktree = vi.fn(() => Effect.void); + const jjRemoveWorktree = vi.fn(() => Effect.void); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + yield* workflow.removeWorktree({ cwd: "/jj-project", path: "/worktrees/pre-flip" }); + + assert.equal(gitRemoveWorktree.mock.calls.length, 1); + assert.equal(jjRemoveWorktree.mock.calls.length, 0); + }).pipe( + Effect.provide( + makeLayer({ + detect: ({ cwd }) => + Effect.succeed(handleFor(cwd === "/worktrees/pre-flip" ? "git" : "jj")), + git: { + removeWorktree: gitRemoveWorktree, + execute: () => + Effect.succeed({ + stdout: "/repo/.git\n", + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), + stdoutTruncated: false, + stderrTruncated: false, + }), + }, + jj: { removeWorktree: jjRemoveWorktree }, }), ), ); }); + + it.effect( + "reports ambiguous ownership when no directory, registration, or record survives", + () => { + const jjRemoveWorktree = vi.fn(() => Effect.void); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow + .removeWorktree({ cwd: "/jj-project", path: "/worktrees/vanished" }) + .pipe(Effect.flip); + assert.include(error.detail, "original VCS owner"); + assert.equal(jjRemoveWorktree.mock.calls.length, 0); + }).pipe( + Effect.provide( + makeLayer({ + detect: ({ cwd }) => Effect.succeed(cwd === "/jj-project" ? handleFor("jj") : null), + jj: { + removeWorktree: jjRemoveWorktree, + registeredWorkspaces: () => Effect.succeed([]), + }, + git: { + execute: (input) => + Effect.succeed({ + stdout: input.operation === "WorkspaceOwnership.commonDir" ? "/repo/.git\n" : "", + stderr: "", + exitCode: ChildProcessSpawner.ExitCode( + input.operation === "WorkspaceOwnership.read" ? 1 : 0, + ), + stdoutTruncated: false, + stderrTruncated: false, + }), + }, + }), + ), + ); + }, + ); }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index a1e31d9bc0fc..a7a10fcff748 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -1,6 +1,9 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { GitManagerError, @@ -26,91 +29,131 @@ import { type VcsStatusLocalResult, type VcsStatusRemoteResult, type VcsStatusResult, + type VcsError, } from "@t3tools/contracts"; +import * as JjWorkflow from "../jj/JjWorkflow.ts"; import * as GitManager from "./GitManager.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import { ServerSettingsService } from "../serverSettings.ts"; +import { makeWorkspaceOwnership, type WorkspaceKind } from "../vcs/WorkspaceOwnership.ts"; +import { inspectWorkspaceFiles } from "../vcs/WorkspaceFileSafety.ts"; +import { workspaceNameForRef } from "../jj/JjWorkspaceNaming.ts"; +import { workspaceSettings } from "../project/WorkspaceSettings.ts"; +export interface WorkspaceCleanupInspection { + readonly revision: string; + readonly refName: string | null; + readonly hasWorkingTreeChanges: boolean; + readonly hasUnpreservedFiles: boolean; + readonly isSecondary: boolean; +} + +/** + * The workflow surface every VCS kind implements. `GitWorkflowService` routes by kind; the Git and + * Jujutsu implementations both satisfy this type, so a future change to a shared signature fails to + * compile in both lanes instead of silently drifting in one. + */ +export interface VcsWorkflowOps { + readonly hasCommit: (input: { + readonly cwd: string; + readonly refName: string; + }) => Effect.Effect; + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + options?: GitManager.GitRemoteStatusOptions, + ) => Effect.Effect; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitManager.GitRunStackedActionOptions, + ) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + options?: GitVcsDriver.CreateWorktreeOptions, + ) => Effect.Effect; + readonly listLocalBranchNames: (cwd: string) => Effect.Effect; + readonly fetchRemote: (input: { + readonly cwd: string; + readonly remoteName: string; + readonly refName?: string; + }) => Effect.Effect; + readonly remoteExists: (input: { + readonly cwd: string; + readonly remoteName: string; + }) => Effect.Effect; + readonly remoteBranchExists: (input: { + readonly cwd: string; + readonly remoteName: string; + readonly refName: string; + }) => Effect.Effect; + readonly resolveRemoteTrackingCommit: (input: { + readonly cwd: string; + readonly refName: string; + readonly fallbackRemoteName: string; + }) => Effect.Effect< + { readonly commitSha: string; readonly remoteRefName: string }, + GitCommandError + >; + readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; + readonly pruneWorktrees: (input: { + readonly cwd: string; + }) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + readonly renameBranch: (input: { + readonly cwd: string; + readonly oldBranch: string; + readonly newBranch: string; + }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>; +} + +type WorkflowKind = "git" | "jj"; + +/** Routes every workflow call to the driver that owns the directory it names. */ export class GitWorkflowService extends Context.Service< GitWorkflowService, - { - readonly isRepository: (cwd: string) => Effect.Effect; - readonly hasCommit: (input: { - readonly cwd: string; - readonly refName: string; - }) => Effect.Effect; - readonly status: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly localStatus: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly remoteStatus: ( - input: VcsStatusInput, - options?: GitManager.GitRemoteStatusOptions, - ) => Effect.Effect; - readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; - readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; - readonly invalidateStatus: (cwd: string) => Effect.Effect; - readonly pullCurrentBranch: (cwd: string) => Effect.Effect; - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitManager.GitRunStackedActionOptions, - ) => Effect.Effect; - readonly resolvePullRequest: ( - input: GitPullRequestRefInput, - ) => Effect.Effect; - readonly preparePullRequestThread: ( - input: GitPreparePullRequestThreadInput, - ) => Effect.Effect; - readonly listRefs: ( - input: VcsListRefsInput, - ) => Effect.Effect; - readonly createWorktree: ( - input: VcsCreateWorktreeInput, + VcsWorkflowOps & { + readonly ensureWorkspace: ( + input: VcsCreateWorktreeInput & { readonly path: string }, options?: GitVcsDriver.CreateWorktreeOptions, - ) => Effect.Effect; - readonly fetchRemote: (input: { - readonly cwd: string; - readonly remoteName: string; - readonly refName?: string; - }) => Effect.Effect; - readonly remoteExists: (input: { - readonly cwd: string; - readonly remoteName: string; - }) => Effect.Effect; - readonly remoteBranchExists: (input: { - readonly cwd: string; - readonly remoteName: string; - readonly refName: string; - }) => Effect.Effect; - readonly resolveRemoteTrackingCommit: (input: { - readonly cwd: string; - readonly refName: string; - readonly fallbackRemoteName: string; - }) => Effect.Effect< - { readonly commitSha: string; readonly remoteRefName: string }, - GitCommandError - >; - readonly removeWorktree: ( - input: VcsRemoveWorktreeInput, ) => Effect.Effect; - readonly pruneWorktrees: (input: { + readonly inspectWorkspaceForCleanup: ( + cwd: string, + ) => Effect.Effect; + readonly workspaceIntegrationBase: (input: { readonly cwd: string; - }) => Effect.Effect; - readonly createRef: ( - input: VcsCreateRefInput, - ) => Effect.Effect; - readonly switchRef: ( - input: VcsSwitchRefInput, - ) => Effect.Effect; - readonly renameBranch: (input: { + readonly refresh: boolean; + }) => Effect.Effect; + readonly isRevisionAncestor: (input: { readonly cwd: string; - readonly oldBranch: string; - readonly newBranch: string; - }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>; + readonly from: string; + readonly to: string; + }) => Effect.Effect; + readonly branchPullRequest: GitManager.GitManager["Service"]["branchPullRequest"]; + /** Whether a Git or Jujutsu driver owns this directory. */ + readonly isRepository: (cwd: string) => Effect.Effect; } >()("t3/git/GitWorkflowService") {} @@ -156,125 +199,197 @@ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; const gitManager = yield* GitManager.GitManager; + const jjWorkflow = yield* JjWorkflow.JjWorkflow; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const ownership = makeWorkspaceOwnership({ fileSystem, path, execute: git.execute }); + const workspaceOps = { git, jj: jjWorkflow }; - const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( - operation: string, - cwd: string, - ) { - const handle = yield* registry.resolve({ cwd }).pipe( - Effect.mapError( - (cause) => - new GitManagerError({ + const commandFailure = (operation: string, cwd: string) => + Effect.mapError((cause) => + Schema.is(GitCommandError)(cause) + ? cause + : new GitCommandError({ operation, + command: "vcs", cwd, - detail: "Failed to resolve the VCS driver for this Git workflow.", + detail: "The workspace operation failed.", cause, }), - ), ); - if (handle.kind !== "git") { - return yield* new GitManagerError({ - operation, - cwd, - detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, - }); - } - }); - const ensureGitCommand = Effect.fn("GitWorkflowService.ensureGitCommand")(function* ( - operation: string, - cwd: string, - ) { - const handle = yield* registry.resolve({ cwd }).pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - operation, - command: "vcs-route", - cwd, - detail: "Failed to resolve the VCS driver for this Git command.", - cause, - }), - ), - ); - if (handle.kind !== "git") { - return yield* new GitCommandError({ - operation, - command: "vcs-route", - cwd, - detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, - }); - } - }); - - const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")( - function* (operation: string, cwd: string) { - const handle = yield* registry.detect({ cwd }).pipe( - Effect.mapError( - (cause) => - new GitManagerError({ - operation, - cwd, - detail: "Failed to detect a VCS repository for this Git workflow.", - cause, - }), + /** + * The driver kind that owns a project cwd. `null` means "not a repository", which is what the + * `nonRepository*` shapes already express, and an unknown kind degrades to it too. A detection + * *failure* is a different thing and stays in the error channel: reporting a healthy repository + * as absent hides the real reason from every caller and from the user. + */ + const resolveWorkflowKind = (cwd: string): Effect.Effect => + registry + .detect({ cwd }) + .pipe( + Effect.map((handle) => + handle?.kind === "git" || handle?.kind === "jj" ? handle.kind : null, ), ); - if (!handle) { - return false; + + const resolveWorkspaceKind = Effect.fn("GitWorkflowService.resolveWorkspaceKind")( + function* (input: { readonly cwd: string; readonly path: string; readonly refName?: string }) { + const detected = yield* resolveWorkflowKind(input.path); + if (detected !== null) return detected; + const remembered = yield* ownership.read(input); + if (remembered !== null) return remembered; + const repositoryCwd = yield* ownership.repositoryCwd(input.cwd); + const registered = yield* git.execute({ + operation: "GitWorkflowService.registeredWorkspaces", + cwd: repositoryCwd, + args: ["worktree", "list", "--porcelain", "-z"], + }); + const normalized = yield* ownership.normalize(input.path); + for (const row of registered.stdout.split("\0")) { + if ( + row.startsWith("worktree ") && + (yield* ownership.normalize(row.slice(9))) === normalized + ) + return "git" as const; } - if (handle.kind !== "git") { - return yield* new GitManagerError({ - operation, - cwd, - detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, - }); + if ((yield* resolveWorkflowKind(input.cwd)) === "jj") { + const workspaces = yield* jjWorkflow.registeredWorkspaces(input.cwd); + for (const workspace of workspaces) { + if ( + workspace.root !== null && + (yield* ownership.normalize(workspace.root)) === normalized + ) + return "jj" as const; + if ( + workspace.root === null && + input.refName !== undefined && + workspace.name === workspaceNameForRef(input.refName) + ) + return "jj" as const; + } } - return true; + return yield* new GitCommandError({ + operation: "GitWorkflowService.resolveWorkspaceKind", + command: "vcs", + cwd: input.cwd, + detail: `The original VCS owner of ${input.path} is unavailable. Restore its registration before resuming.`, + }); }, ); - const detectGitRepositoryForCommand = Effect.fn( - "GitWorkflowService.detectGitRepositoryForCommand", - )(function* (operation: string, cwd: string) { - const handle = yield* registry.detect({ cwd }).pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - operation, - command: "vcs-route", - cwd, - detail: "Failed to detect a VCS repository for this Git command.", - cause, - }), - ), - ); - if (!handle) { - return false; - } - if (handle.kind !== "git") { - return yield* new GitCommandError({ + const createWorkspaceWithKind = Effect.fn("GitWorkflowService.createWorkspaceWithKind")( + function* ( + kind: WorkspaceKind, + input: VcsCreateWorktreeInput, + options?: GitVcsDriver.CreateWorktreeOptions, + ) { + const settings = yield* workspaceSettings(input).pipe( + Effect.provideService(ServerSettingsService, settingsService), + ); + const result = yield* workspaceOps[kind].createWorktree(input, { + ...options, + directory: options?.directory ?? settings.worktreeDirectory, + projectFolders: options?.projectFolders ?? settings.worktreeProjectFolders, + submodules: options?.submodules ?? settings.worktreeSubmodules, + }); + yield* ownership.remember({ cwd: input.cwd, path: result.worktree.path, kind }); + return result; + }, + ); + + const notARepositoryCommand = (operation: string, cwd: string) => + Effect.fail( + new GitCommandError({ operation, command: "vcs-route", cwd, - detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, - }); - } - return true; - }); + detail: `The ${operation} command found no version control repository here.`, + }), + ); + + const notARepositoryWorkflow = (operation: string, cwd: string) => + Effect.fail( + new GitManagerError({ + operation, + cwd, + detail: `The ${operation} workflow found no version control repository here. (${cwd})`, + }), + ); + + const undetectableCommand = (operation: string, cwd: string) => + Effect.mapError( + (cause: VcsError) => + new GitCommandError({ + operation, + command: "vcs-route", + cwd, + detail: "Could not determine which version control system manages this directory.", + cause, + }), + ); - const routeGitManager = - ( + const undetectableWorkflow = (operation: string, cwd: string) => + Effect.mapError( + (cause: VcsError) => + new GitManagerError({ + operation, + cwd, + detail: `Could not determine which version control system manages this directory. (${cwd})`, + cause, + }), + ); + + const commandRouting = { + kind: (operation: string, cwd: string) => + resolveWorkflowKind(cwd).pipe(undetectableCommand(operation, cwd)), + notARepository: notARepositoryCommand, + }; + + const workflowRouting = { + kind: (operation: string, cwd: string) => + resolveWorkflowKind(cwd).pipe(undetectableWorkflow(operation, cwd)), + notARepository: notARepositoryWorkflow, + }; + + type Routing = { + readonly kind: (operation: string, cwd: string) => Effect.Effect; + readonly notARepository: (operation: string, cwd: string) => Effect.Effect; + }; + + type RouteArms = { + readonly git: (input: Input) => Effect.Effect; + readonly jj: (input: Input) => Effect.Effect; + }; + + /** Routes on the project cwd; `fallback` answers for a directory that is not a repository. */ + const routeOr = + ( operation: string, - run: (input: Input) => Effect.Effect, + routing: Routing, + fallback: (operation: string, cwd: string) => Effect.Effect, + arms: RouteArms, ) => (input: Input) => - ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); + routing + .kind(operation, input.cwd) + .pipe( + Effect.flatMap((kind) => + kind === null ? fallback(operation, input.cwd) : arms[kind](input), + ), + ); + + /** Routes on the project cwd, failing when the directory is not a repository at all. */ + const route = ( + operation: string, + routing: Routing, + arms: RouteArms, + ) => routeOr(operation, routing, routing.notARepository, arms); return GitWorkflowService.of({ isRepository: (cwd) => registry.detect({ cwd }).pipe( - Effect.map((handle) => handle?.kind === "git"), + Effect.map((handle) => handle !== null && handle.kind !== "unknown"), Effect.mapError( (cause) => new GitManagerError({ @@ -285,118 +400,223 @@ export const make = Effect.gen(function* () { }), ), ), - hasCommit: (input) => - ensureGitCommand("GitWorkflowService.hasCommit", input.cwd).pipe( - Effect.andThen( - git.execute({ + hasCommit: route("GitWorkflowService.hasCommit", commandRouting, { + git: (input: { readonly cwd: string; readonly refName: string }) => + git + .execute({ operation: "GitWorkflowService.hasCommit", cwd: input.cwd, args: ["rev-parse", "--verify", `${input.refName}^{commit}`], allowNonZeroExit: true, - }), - ), - Effect.map((result) => result.exitCode === 0), - ), - status: (input) => - detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( - Effect.flatMap((isGitRepository) => - isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()), - ), - ), - localStatus: (input) => - detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe( - Effect.flatMap((isGitRepository) => - isGitRepository - ? gitManager.localStatus(input) - : Effect.succeed(nonRepositoryLocalStatus()), - ), - ), + }) + .pipe(Effect.map((result) => result.exitCode === 0)), + jj: jjWorkflow.hasCommit, + }), + status: routeOr( + "GitWorkflowService.status", + workflowRouting, + () => Effect.succeed(nonRepositoryStatus()), + { git: gitManager.status, jj: jjWorkflow.status }, + ), + localStatus: routeOr( + "GitWorkflowService.localStatus", + workflowRouting, + () => Effect.succeed(nonRepositoryLocalStatus()), + { git: gitManager.localStatus, jj: jjWorkflow.localStatus }, + ), remoteStatus: (input, options) => - detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( - Effect.flatMap((isGitRepository) => - isGitRepository ? gitManager.remoteStatus(input, options) : Effect.succeed(null), - ), - ), - invalidateLocalStatus: gitManager.invalidateLocalStatus, - invalidateRemoteStatus: gitManager.invalidateRemoteStatus, - invalidateStatus: gitManager.invalidateStatus, + routeOr("GitWorkflowService.remoteStatus", workflowRouting, () => Effect.succeed(null), { + git: (statusInput: VcsStatusInput) => gitManager.remoteStatus(statusInput, options), + jj: (statusInput: VcsStatusInput) => jjWorkflow.remoteStatus(statusInput, options), + })(input), + // Pure cache drops with no error channel: resolving the kind first would cost a detect on a + // hot path for no benefit. + invalidateLocalStatus: (cwd) => + Effect.all([gitManager.invalidateLocalStatus(cwd), jjWorkflow.invalidateLocalStatus(cwd)], { + discard: true, + }), + invalidateRemoteStatus: (cwd) => + Effect.all([gitManager.invalidateRemoteStatus(cwd), jjWorkflow.invalidateRemoteStatus(cwd)], { + discard: true, + }), + invalidateStatus: (cwd) => + Effect.all([gitManager.invalidateStatus(cwd), jjWorkflow.invalidateStatus(cwd)], { + discard: true, + }), pullCurrentBranch: (cwd) => - ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( - Effect.andThen(git.pullCurrentBranch(cwd)), - ), + route("GitWorkflowService.pullCurrentBranch", commandRouting, { + git: () => git.pullCurrentBranch(cwd), + jj: () => jjWorkflow.pullCurrentBranch(cwd), + })({ cwd }), runStackedAction: (input, options) => - ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( - Effect.andThen(gitManager.runStackedAction(input, options)), - ), - resolvePullRequest: routeGitManager( - "GitWorkflowService.resolvePullRequest", - gitManager.resolvePullRequest, - ), - preparePullRequestThread: routeGitManager( - "GitWorkflowService.preparePullRequestThread", - gitManager.preparePullRequestThread, - ), - listRefs: (input) => - detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe( - Effect.flatMap((isGitRepository) => - isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), + route("GitWorkflowService.runStackedAction", workflowRouting, { + git: (actionInput: GitRunStackedActionInput) => + gitManager.runStackedAction(actionInput, options), + jj: (actionInput: GitRunStackedActionInput) => + jjWorkflow.runStackedAction(actionInput, options), + })(input), + resolvePullRequest: route("GitWorkflowService.resolvePullRequest", workflowRouting, { + git: gitManager.resolvePullRequest, + jj: jjWorkflow.resolvePullRequest, + }), + preparePullRequestThread: (input) => + route("GitWorkflowService.preparePullRequestThread", workflowRouting, { + git: gitManager.preparePullRequestThread, + jj: jjWorkflow.preparePullRequestThread, + })(input).pipe( + Effect.tap((result) => + result.worktreePath === null + ? Effect.void + : Effect.gen(function* () { + const workspacePath = result.worktreePath; + if (workspacePath === null) return; + const kind = yield* resolveWorkspaceKind({ cwd: input.cwd, path: workspacePath }); + yield* ownership.remember({ cwd: input.cwd, path: workspacePath, kind }); + }).pipe(commandFailure("GitWorkflowService.preparePullRequestThread", input.cwd)), ), ), + listRefs: routeOr( + "GitWorkflowService.listRefs", + commandRouting, + () => Effect.succeed(nonRepositoryListRefs()), + { git: git.listRefs, jj: jjWorkflow.listRefs }, + ), createWorktree: (input, options) => - ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( - Effect.andThen( - Effect.gen(function* () { - const settings = yield* settingsService.getSettings.pipe( - Effect.orElseSucceed(() => null), - ); - return yield* git.createWorktree(input, { - ...options, - ...(settings - ? { - directory: settings.worktreeDirectory, - projectFolders: settings.worktreeProjectFolders, - } - : {}), - }); - }), - ), - ), - fetchRemote: (input) => - ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( - Effect.andThen(git.fetchRemote(input)), - ), - remoteExists: (input) => - ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe( - Effect.andThen(git.remoteExists(input)), - ), - remoteBranchExists: (input) => - ensureGitCommand("GitWorkflowService.remoteBranchExists", input.cwd).pipe( - Effect.andThen(git.remoteBranchExists(input)), - ), - resolveRemoteTrackingCommit: (input) => - ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( - Effect.andThen(git.resolveRemoteTrackingCommit(input)), - ), + route("GitWorkflowService.createWorktree", commandRouting, { + git: (worktreeInput: VcsCreateWorktreeInput) => + createWorkspaceWithKind("git", worktreeInput, options).pipe( + commandFailure("GitWorkflowService.createWorktree", input.cwd), + ), + jj: (worktreeInput: VcsCreateWorktreeInput) => + createWorkspaceWithKind("jj", worktreeInput, options).pipe( + commandFailure("GitWorkflowService.createWorktree", input.cwd), + ), + })(input), + ensureWorkspace: (input, options) => + Effect.gen(function* () { + const kind = yield* resolveWorkspaceKind(input); + yield* ownership.remember({ cwd: input.cwd, path: input.path, kind }); + if (yield* fileSystem.exists(input.path)) return; + const repositoryCwd = yield* ownership.repositoryCwd(input.cwd); + yield* workspaceOps[kind].pruneWorktrees({ cwd: repositoryCwd }); + yield* createWorkspaceWithKind(kind, input, options); + }).pipe(commandFailure("GitWorkflowService.ensureWorkspace", input.cwd)), + inspectWorkspaceForCleanup: (cwd) => + Effect.gen(function* () { + if ((yield* resolveWorkflowKind(cwd)) === "jj") + return yield* jjWorkflow.inspectWorkspaceForCleanup(cwd); + const local = yield* git.statusDetailsLocal(cwd); + const head = yield* git.resolveCommit({ cwd, revision: "HEAD" }); + const marker = yield* fileSystem.stat(path.join(cwd, ".git")); + const unpreserved = yield* inspectWorkspaceFiles({ + cwd, + repositoryCwd: cwd, + revision: head.commitSha, + fileSystem, + path, + execute: git.execute, + }); + return { + revision: head.commitSha, + refName: local.branch, + hasWorkingTreeChanges: local.hasWorkingTreeChanges, + hasUnpreservedFiles: unpreserved.hasUnpreservedFiles, + isSecondary: marker.type === "File", + }; + }).pipe(commandFailure("GitWorkflowService.inspectWorkspaceForCleanup", cwd)), + workspaceIntegrationBase: (input) => + Effect.gen(function* () { + if ((yield* resolveWorkflowKind(input.cwd)) === "jj") + return yield* jjWorkflow.workspaceIntegrationBase(input); + const remote = yield* git.resolvePrimaryRemoteName(input.cwd); + const refName = yield* git.resolveDefaultBranchName(input.cwd, remote); + if (refName === null) return null; + if (input.refresh) + yield* git.fetchRemoteTrackingBranch({ + cwd: input.cwd, + remoteName: remote, + remoteBranch: refName, + }); + return (yield* git.resolveCommit({ + cwd: input.cwd, + revision: `refs/remotes/${remote}/${refName}`, + })).commitSha; + }).pipe(commandFailure("GitWorkflowService.workspaceIntegrationBase", input.cwd)), + isRevisionAncestor: (input) => + Effect.gen(function* () { + const cwd = yield* ownership.repositoryCwd(input.cwd); + const result = yield* git.execute({ + operation: "GitWorkflowService.isRevisionAncestor", + cwd, + args: ["merge-base", "--is-ancestor", input.from, input.to], + allowNonZeroExit: true, + }); + if (result.exitCode > 1) + return yield* new GitCommandError({ + operation: "GitWorkflowService.isRevisionAncestor", + command: "git merge-base", + cwd, + detail: result.stderr, + }); + return result.exitCode === 0; + }).pipe(commandFailure("GitWorkflowService.isRevisionAncestor", input.cwd)), + branchPullRequest: (input, options) => + route("GitWorkflowService.branchPullRequest", workflowRouting, { + git: (request: typeof input) => gitManager.branchPullRequest(request, options), + jj: (request: typeof input) => jjWorkflow.branchPullRequest(request, options), + })(input), + listLocalBranchNames: (cwd) => + route("GitWorkflowService.listLocalBranchNames", commandRouting, { + git: () => git.listLocalBranchNames(cwd), + jj: () => jjWorkflow.listLocalBranchNames(cwd), + })({ cwd }), + fetchRemote: route("GitWorkflowService.fetchRemote", commandRouting, { + git: git.fetchRemote, + jj: jjWorkflow.fetchRemote, + }), + remoteExists: route("GitWorkflowService.remoteExists", commandRouting, { + git: git.remoteExists, + jj: jjWorkflow.remoteExists, + }), + remoteBranchExists: route("GitWorkflowService.remoteBranchExists", commandRouting, { + git: git.remoteBranchExists, + jj: jjWorkflow.remoteBranchExists, + }), + resolveRemoteTrackingCommit: route( + "GitWorkflowService.resolveRemoteTrackingCommit", + commandRouting, + { + git: git.resolveRemoteTrackingCommit, + jj: jjWorkflow.resolveRemoteTrackingCommit, + }, + ), + // Dispatched on the workspace path, not the project: a Git worktree inside a colocated jj + // project must keep being removable after the detection flip. removeWorktree: (input) => - ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( - Effect.andThen(git.removeWorktree(input)), - ), - pruneWorktrees: (input) => - ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe( - Effect.andThen(git.pruneWorktrees(input)), - ), - createRef: (input) => - ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( - Effect.andThen(git.createRef(input)), - ), - switchRef: (input) => - ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( - Effect.andThen(Effect.scoped(git.switchRef(input))), - ), - renameBranch: (input) => - ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( - Effect.andThen(git.renameBranch(input)), - ), + Effect.gen(function* () { + const kind = yield* resolveWorkspaceKind(input); + yield* ownership.remember({ cwd: input.cwd, path: input.path, kind }); + const repositoryCwd = yield* ownership.repositoryCwd(input.cwd); + yield* workspaceOps[kind].removeWorktree({ ...input, cwd: repositoryCwd }); + }).pipe(commandFailure("GitWorkflowService.removeWorktree", input.cwd)), + pruneWorktrees: route("GitWorkflowService.pruneWorktrees", commandRouting, { + git: git.pruneWorktrees, + jj: jjWorkflow.pruneWorktrees, + }), + createRef: route("GitWorkflowService.createRef", commandRouting, { + git: git.createRef, + jj: jjWorkflow.createRef, + }), + // The Git driver's `switchRef` needs a `Scope` the service closes locally; the declared type + // carries none, and leaking it would reach every caller. The jj arm needs no scope. + switchRef: route("GitWorkflowService.switchRef", commandRouting, { + git: (input) => Effect.scoped(git.switchRef(input)), + jj: jjWorkflow.switchRef, + }), + renameBranch: route("GitWorkflowService.renameBranch", workflowRouting, { + git: git.renameBranch, + jj: jjWorkflow.renameBranch, + }), }); }); diff --git a/apps/server/src/jj/JjFailure.ts b/apps/server/src/jj/JjFailure.ts new file mode 100644 index 000000000000..f6f3c9d00ac3 --- /dev/null +++ b/apps/server/src/jj/JjFailure.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; + +import { GitCommandError } from "@t3tools/contracts"; + +export const jjFailure = (operation: string, cwd: string, detail: string, cause?: unknown) => + new GitCommandError({ + operation, + command: "jj", + cwd, + detail, + ...(cause !== undefined ? { cause } : {}), + }); + +export const mapJjFailure = (operation: string, cwd: string, detail: string) => + Effect.mapError((cause: unknown) => jjFailure(operation, cwd, detail, cause)); diff --git a/apps/server/src/jj/JjPullRequestThread.test.ts b/apps/server/src/jj/JjPullRequestThread.test.ts new file mode 100644 index 000000000000..b19e861adbab --- /dev/null +++ b/apps/server/src/jj/JjPullRequestThread.test.ts @@ -0,0 +1,205 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import type { ChangeRequest, ThreadId } from "@t3tools/contracts"; + +import * as GitManager from "../git/GitManager.ts"; +import type * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as SourceControlProvider from "../sourceControl/SourceControlProvider.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as JjVcsDriver from "../vcs/JjVcsDriver.ts"; +import { describeJj, runGit, runJj, withJjRepo } from "../vcs/testing/JjTestSupport.ts"; +import { makeJjPullRequestThread, type JjPullRequestThreadOps } from "./JjPullRequestThread.ts"; +import { makeJjRefs } from "./JjRefs.ts"; +import { makeJjRemotes } from "./JjRemotes.ts"; +import { makeJjWorkspaces } from "./JjWorkspaces.ts"; + +const changeRequest = (input: { + readonly headRefName: string; + readonly isCrossRepository?: boolean; +}): ChangeRequest => + ({ + provider: "github", + number: 7, + title: "Add the thing", + url: "https://example.com/pull/7", + baseRefName: "main", + headRefName: input.headRefName, + state: "open", + ...(input.isCrossRepository === true ? { isCrossRepository: true } : {}), + }) as ChangeRequest; + +const providersFor = (summary: ChangeRequest) => + SourceControlProviderRegistry.SourceControlProviderRegistry.pipe( + Effect.provide( + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => undefined, + resolve: () => + SourceControlProvider.SourceControlProvider.pipe( + Effect.provide( + Layer.mock(SourceControlProvider.SourceControlProvider)({ + kind: "github", + getChangeRequest: () => Effect.succeed(summary), + }), + ), + ), + }), + ), + ); + +interface ThreadFixture { + readonly driver: JjVcsDriver.JjVcsDriverShape; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly ops: JjPullRequestThreadOps; + readonly setupScriptRuns: Array; + readonly root: string; + readonly remotePath: string; +} + +const withRepo = ( + input: { readonly summary: ChangeRequest }, + use: (fixture: ThreadFixture) => Effect.Effect, +) => + withJjRepo({ prefix: "t3-jj-pr-", withRemote: true }, (repo) => + Effect.gen(function* () { + const { base, driver, fileSystem, path, process, remotePath, root } = repo; + const remotes = makeJjRemotes({ driver, process }); + const refs = yield* makeJjRefs({ driver, process }); + const workspaces = makeJjWorkspaces({ + driver, + process, + fileSystem, + path, + worktreesDir: path.join(base, "worktrees"), + bookmarkTo: remotes.bookmarkTo, + }); + const setupScriptRuns: Array = []; + const projectSetupScriptRunner: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] = + { + runForThread: (runInput) => + Effect.sync(() => { + setupScriptRuns.push(runInput.worktreePath); + return { status: "no-script" as const }; + }), + }; + + const ops = makeJjPullRequestThread({ + driver, + process, + refs, + workspaces, + gitManager: yield* GitManager.GitManager.pipe( + Effect.provide(Layer.mock(GitManager.GitManager)({})), + ), + sourceControlProviders: yield* providersFor(input.summary), + projectSetupScriptRunner, + invalidateStatus: () => Effect.void, + }); + + return yield* use({ driver, fileSystem, path, ops, setupScriptRuns, root, remotePath }); + }), + ); + +describeJj("JjPullRequestThread worktree mode", () => { + it.effect("runs the project setup script after creating and after reusing the workspace", () => + withRepo( + { summary: changeRequest({ headRefName: "feat/pr" }) }, + ({ ops, root, setupScriptRuns }) => + Effect.gen(function* () { + yield* runJj(root, ["bookmark", "create", "feat/pr", "-r", "@-"]); + yield* runJj(root, ["git", "push", "--bookmark", "feat/pr", "--remote", "origin"]); + + const first = yield* ops.preparePullRequestThread({ + cwd: root, + reference: "7", + mode: "worktree", + threadId: "thread-1" as ThreadId, + }); + const second = yield* ops.preparePullRequestThread({ + cwd: root, + reference: "7", + mode: "worktree", + threadId: "thread-1" as ThreadId, + }); + + assert.equal(first.worktreePath, second.worktreePath); + assert.equal(first.branch, "feat/pr"); + assert.isTrue(first.isOnPullRequestHead); + assert.deepStrictEqual(setupScriptRuns, [first.worktreePath, second.worktreePath]); + }), + ), + ); + + it.effect("materialises a fork head that only exists as refs/pull//head", () => + withRepo( + { summary: changeRequest({ headRefName: "their-branch", isCrossRepository: true }) }, + ({ fileSystem, ops, path, remotePath, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "fork.txt"), "fork\n"); + yield* runJj(root, ["commit", "-m", "fork work"]); + yield* runJj(root, ["bookmark", "create", "fork-head", "-r", "@-"]); + yield* runJj(root, ["git", "push", "--bookmark", "fork-head", "--remote", "origin"]); + const forkCommit = (yield* runGit(remotePath, [ + "rev-parse", + "refs/heads/fork-head", + ])).trim(); + yield* runGit(remotePath, ["update-ref", "refs/pull/7/head", forkCommit]); + yield* runGit(remotePath, ["update-ref", "-d", "refs/heads/fork-head"]); + + const prepared = yield* ops.preparePullRequestThread({ + cwd: root, + reference: "7", + mode: "worktree", + }); + + assert.equal(prepared.branch, "t3code/pr-7/their-branch"); + assert.isTrue(prepared.isOnPullRequestHead); + }), + ), + ); +}); + +describeJj("JjPullRequestThread local mode", () => { + it.effect("refuses to strand an unbookmarked commit before moving onto the head", () => + withRepo( + { summary: changeRequest({ headRefName: "feat/pr" }) }, + ({ fileSystem, ops, path, root }) => + Effect.gen(function* () { + yield* runJj(root, ["bookmark", "create", "feat/pr", "-r", "@-"]); + yield* runJj(root, ["git", "push", "--bookmark", "feat/pr", "--remote", "origin"]); + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + yield* fileSystem.writeFileString(path.join(root, "agent.txt"), "agent\n"); + yield* runJj(root, ["commit", "-m", "agent work"]); + + const failure = yield* ops + .preparePullRequestThread({ cwd: root, reference: "7", mode: "local" }) + .pipe(Effect.flip); + + assert.include(failure.detail, "not on a bookmark yet"); + }), + ), + ); + + it.effect("moves onto the head and reports being on it", () => + withRepo({ summary: changeRequest({ headRefName: "feat/pr" }) }, ({ ops, root }) => + Effect.gen(function* () { + yield* runJj(root, ["bookmark", "create", "feat/pr", "-r", "@-"]); + yield* runJj(root, ["git", "push", "--bookmark", "feat/pr", "--remote", "origin"]); + + const prepared = yield* ops.preparePullRequestThread({ + cwd: root, + reference: "7", + mode: "local", + }); + + assert.equal(prepared.branch, "feat/pr"); + assert.equal(prepared.worktreePath, null); + assert.isTrue(prepared.isOnPullRequestHead); + }), + ), + ); +}); diff --git a/apps/server/src/jj/JjPullRequestThread.ts b/apps/server/src/jj/JjPullRequestThread.ts new file mode 100644 index 000000000000..d2b641070527 --- /dev/null +++ b/apps/server/src/jj/JjPullRequestThread.ts @@ -0,0 +1,221 @@ +import * as Effect from "effect/Effect"; + +import { + GitCommandError, + type GitPreparePullRequestThreadInput, + type GitPreparePullRequestThreadResult, +} from "@t3tools/contracts"; + +import type * as GitManager from "../git/GitManager.ts"; +import type { CreateWorktreeOptions } from "../vcs/GitVcsDriver.ts"; +import { + normalizePullRequestReference, + resolvePullRequestWorktreeLocalBranchName, + toResolvedPullRequest, +} from "../git/GitManager.ts"; +import type { VcsWorkflowOps } from "../git/GitWorkflowService.ts"; +import type * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import type * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as JjProcess from "../vcs/JjProcess.ts"; +import { localBookmarkRevset } from "../vcs/JjRevset.ts"; +import type { JjVcsDriverShape } from "../vcs/JjVcsDriver.ts"; +import type * as VcsProcess from "../vcs/VcsProcess.ts"; +import { jjFailure, mapJjFailure } from "./JjFailure.ts"; +import type { JjRefsOps } from "./JjRefs.ts"; +import { trackRemoteBookmark } from "./JjRemotes.ts"; +import type { JjWorkspaceOps } from "./JjWorkspaces.ts"; + +const FETCH_TIMEOUT_MS = 120_000; + +export interface JjPullRequestThreadDeps { + readonly driver: JjVcsDriverShape; + readonly gitManager: GitManager.GitManager["Service"]; + readonly process: VcsProcess.VcsProcess["Service"]; + readonly refs: JjRefsOps; + readonly workspaces: JjWorkspaceOps; + readonly sourceControlProviders: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]; + readonly projectSetupScriptRunner: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; + readonly invalidateStatus: (cwd: string) => Effect.Effect; +} + +export interface JjPullRequestThreadOps { + readonly resolvePullRequest: VcsWorkflowOps["resolvePullRequest"]; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + options?: CreateWorktreeOptions, + ) => Effect.Effect; +} + +export const makeJjPullRequestThread = (deps: JjPullRequestThreadDeps): JjPullRequestThreadOps => { + const { + driver, + gitManager, + invalidateStatus, + process, + projectSetupScriptRunner, + refs, + sourceControlProviders, + workspaces, + } = deps; + + const run = JjProcess.jjRunner(process); + + /** `gh`/`glab`/`az` read `.git/config` themselves, so hosting calls run from the main workspace. */ + const resolvePullRequest: JjPullRequestThreadOps["resolvePullRequest"] = Effect.fn( + "JjPullRequestThread.resolvePullRequest", + )(function* (input) { + const operation = "JjPullRequestThread.resolvePullRequest"; + const paths = yield* driver + .ensureUsable(operation, input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "This Jujutsu repository cannot be used.")); + return yield* gitManager.resolvePullRequest({ ...input, cwd: paths.mainWorkspaceRoot }); + }); + + const preparePullRequestThread: JjPullRequestThreadOps["preparePullRequestThread"] = Effect.fn( + "JjPullRequestThread.preparePullRequestThread", + )(function* (input, options) { + const operation = "JjPullRequestThread.preparePullRequestThread"; + + const maybeRunSetupScript = (worktreePath: string) => + input.threadId === undefined + ? Effect.void + : projectSetupScriptRunner + .runForThread({ + threadId: input.threadId, + projectCwd: input.cwd, + worktreePath, + }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("JjWorkflow.preparePullRequestThread setup script failed", { + threadId: input.threadId, + worktreePath, + cause, + }).pipe(Effect.asVoid), + ), + ); + + return yield* Effect.gen(function* () { + const paths = yield* driver + .ensureUsable(operation, input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "This Jujutsu repository cannot be used.")); + const hostingCwd = paths.mainWorkspaceRoot; + const provider = yield* sourceControlProviders + .resolve({ cwd: hostingCwd }) + .pipe(mapJjFailure(operation, input.cwd, "Could not resolve the hosting provider.")); + const summary = yield* provider + .getChangeRequest({ + cwd: hostingCwd, + reference: normalizePullRequestReference(input.reference), + }) + .pipe(mapJjFailure(operation, input.cwd, "Could not resolve this pull request.")); + const pullRequest = toResolvedPullRequest(summary); + const localBranch = resolvePullRequestWorktreeLocalBranchName({ + ...pullRequest, + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + }); + + const remoteName = yield* driver + .resolvePrimaryRemoteName(input.cwd) + .pipe(Effect.orElseSucceed(() => null)); + if (summary.isCrossRepository !== true && remoteName !== null) { + yield* run( + operation, + input.cwd, + ["git", "fetch", "--remote", remoteName, "--branch", pullRequest.headBranch], + { timeoutMs: FETCH_TIMEOUT_MS }, + ).pipe(mapJjFailure(operation, input.cwd, "Could not fetch the pull request head.")); + yield* trackRemoteBookmark({ + process, + operation, + cwd: input.cwd, + gitDir: paths.gitDir, + remoteName, + name: pullRequest.headBranch, + }); + } else { + // Hosting refs materialize fork heads without checking out Git behind jj's back. + if (remoteName === null) { + return yield* Effect.fail( + jjFailure(operation, input.cwd, "This repository has no remote to fetch from."), + ); + } + const headRef = + provider.kind === "github" || provider.kind === "forgejo" + ? `refs/pull/${pullRequest.number}/head` + : provider.kind === "gitlab" + ? `refs/merge-requests/${pullRequest.number}/head` + : provider.kind === "bitbucket" + ? `refs/pull-requests/${pullRequest.number}/from` + : null; + if (headRef === null) + return yield* jjFailure( + operation, + input.cwd, + `Opening cross-repository change requests in Jujutsu is not supported for ${provider.kind}.`, + ); + yield* JjProcess.colocatedGitCommand( + process, + operation, + { gitDir: paths.gitDir, cwd: paths.mainWorkspaceRoot }, + ["fetch", remoteName, `+${headRef}:refs/heads/${localBranch}`], + { timeoutMs: FETCH_TIMEOUT_MS }, + ).pipe(mapJjFailure(operation, input.cwd, "Could not fetch the pull request head.")); + yield* run(operation, input.cwd, ["git", "import"], { timeoutMs: FETCH_TIMEOUT_MS }).pipe( + mapJjFailure(operation, input.cwd, "Could not import the pull request head."), + ); + } + yield* JjProcess.exportGitRefs(process, operation, input.cwd).pipe( + mapJjFailure(operation, input.cwd, "Could not synchronize the change-request refs."), + ); + + const headCommit = yield* driver + .changeAt(input.cwd, localBookmarkRevset(localBranch)) + .pipe(Effect.orElseSucceed(() => null)); + + const isOnPullRequestHead = (workspaceCwd: string) => + headCommit === null + ? Effect.succeed(false) + : driver.countRevset(workspaceCwd, `${headCommit.commitId} & ::@`).pipe( + Effect.map((count) => count > 0), + Effect.orElseSucceed(() => false), + ); + + if (input.mode === "local") { + // Never `gh pr checkout`: it writes a git ref and moves HEAD behind jj's back, leaving + // the jj working copy stale. + yield* refs.assertNoStrandedWork({ + cwd: input.cwd, + operation, + targetRefName: localBranch, + }); + yield* run(operation, input.cwd, ["new", localBookmarkRevset(localBranch)], { + timeoutMs: 60_000, + }).pipe(mapJjFailure(operation, input.cwd, `Could not move onto ${localBranch}.`)); + return { + pullRequest, + branch: localBranch, + worktreePath: null, + isOnPullRequestHead: yield* isOnPullRequestHead(input.cwd), + }; + } + + const worktree = yield* workspaces.createWorktree( + { cwd: input.cwd, refName: localBranch, path: null }, + options, + ); + yield* maybeRunSetupScript(worktree.worktree.path); + + return { + pullRequest, + branch: worktree.worktree.refName, + worktreePath: worktree.worktree.path, + isOnPullRequestHead: yield* isOnPullRequestHead(worktree.worktree.path), + }; + }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); + }); + + return { resolvePullRequest, preparePullRequestThread }; +}; diff --git a/apps/server/src/jj/JjRefs.test.ts b/apps/server/src/jj/JjRefs.test.ts new file mode 100644 index 000000000000..690f1b31a8ab --- /dev/null +++ b/apps/server/src/jj/JjRefs.test.ts @@ -0,0 +1,251 @@ +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 JjVcsDriver from "../vcs/JjVcsDriver.ts"; +import { describeJj, runJj, withJjRepo } from "../vcs/testing/JjTestSupport.ts"; +import { makeJjRefs, type JjRefsOps } from "./JjRefs.ts"; +import { makeJjRemotes } from "./JjRemotes.ts"; +import { makeJjWorkspaces, workspaceNameForRef, type JjWorkspaceOps } from "./JjWorkspaces.ts"; + +interface RefsFixture { + readonly driver: JjVcsDriver.JjVcsDriverShape; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly refs: JjRefsOps; + readonly workspaces: JjWorkspaceOps; + readonly root: string; +} + +const withRepo = ( + input: { readonly withRemote?: boolean }, + use: (fixture: RefsFixture) => Effect.Effect, +) => + withJjRepo({ prefix: "t3-jj-refs-", seed: true, ...input }, (repo) => + Effect.gen(function* () { + const { base, driver, fileSystem, path, process, root } = repo; + const remotes = makeJjRemotes({ driver, process }); + const refs = yield* makeJjRefs({ driver, process }); + const workspaces = makeJjWorkspaces({ + driver, + process, + fileSystem, + path, + worktreesDir: path.join(base, "worktrees"), + bookmarkTo: remotes.bookmarkTo, + }); + + return yield* use({ driver, fileSystem, path, refs, workspaces, root }); + }), + ); + +describeJj("JjRefs.listRefs", () => { + it.effect("drops the colocated pseudo-remote and names remote rows like Git does", () => + withRepo({ withRemote: true }, ({ refs, root }) => + Effect.gen(function* () { + const listed = yield* refs.listRefs({ + cwd: root, + refresh: true, + includeMatchingRemoteRefs: true, + }); + + assert.isFalse(listed.refs.some((ref) => ref.name.startsWith("git/"))); + const remoteRow = listed.refs.find((ref) => ref.isRemote === true); + assert.equal(remoteRow?.name, "origin/main"); + assert.equal(remoteRow?.remoteName, "origin"); + }), + ), + ); + + it.effect("reports the segment bookmark as current and the trunk bookmark as default", () => + withRepo({ withRemote: true }, ({ refs, root }) => + Effect.gen(function* () { + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + const listed = yield* refs.listRefs({ cwd: root, refresh: true }); + const main = listed.refs.find((ref) => ref.name === "main"); + + assert.equal(main?.current, true); + assert.equal(main?.isDefault, true); + }), + ), + ); + + it.effect("resolves a bookmark's workspace and never reports the main workspace root", () => + withRepo({}, ({ refs, root, workspaces }) => + Effect.gen(function* () { + const created = yield* workspaces.createWorktree({ + cwd: root, + refName: "main", + newRefName: "feat/threaded", + path: null, + }); + const listed = yield* refs.listRefs({ cwd: root, refresh: true }); + + assert.equal( + listed.refs.find((ref) => ref.name === "feat/threaded")?.worktreePath, + created.worktree.path, + ); + assert.equal(listed.refs.find((ref) => ref.name === "main")?.worktreePath, null); + }), + ), + ); + + it.effect("filters, orders and pages the way the Git implementation does", () => + withRepo({}, ({ refs, root }) => + Effect.gen(function* () { + yield* runJj(root, ["bookmark", "create", "feat/alpha", "-r", "@-"]); + yield* runJj(root, ["bookmark", "create", "feat/beta", "-r", "@-"]); + + const filtered = yield* refs.listRefs({ cwd: root, query: "FEAT/", refresh: true }); + assert.deepStrictEqual(filtered.refs.map((ref) => ref.name).toSorted(), [ + "feat/alpha", + "feat/beta", + ]); + + const firstPage = yield* refs.listRefs({ cwd: root, limit: 1, refKind: "local" }); + assert.equal(firstPage.refs.length, 1); + assert.equal(firstPage.totalCount, 3); + assert.equal(firstPage.nextCursor, 1); + }), + ), + ); +}); + +describeJj("JjRefs.createRef", () => { + it.effect("bookmarks `@` when it holds the work", () => + withRepo({}, ({ driver, fileSystem, path, refs, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "editing.txt"), "editing\n"); + + yield* refs.createRef({ cwd: root, refName: "feat/editing" }); + + const change = yield* driver.currentChange(root); + assert.include(change.localBookmarks, "feat/editing"); + }), + ), + ); + + it.effect("bookmarks `@-` when `@` is empty, so the agent's commits are captured", () => + withRepo({}, ({ driver, fileSystem, path, refs, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "agent.txt"), "agent\n"); + yield* runJj(root, ["commit", "-m", "agent work"]); + + yield* refs.createRef({ cwd: root, refName: "feat/captured" }); + + const change = yield* driver.currentChange(root); + assert.deepStrictEqual([...change.localBookmarks], []); + const parent = yield* driver.changeAt(root, "@-"); + assert.include(parent?.localBookmarks ?? [], "feat/captured"); + }), + ), + ); + + it.effect("bookmarks `@` in a repository with no commits", () => + withJjRepo({ prefix: "t3-jj-refs-empty-" }, ({ driver, process, root }) => + Effect.gen(function* () { + const refs = yield* makeJjRefs({ driver, process }); + + yield* refs.createRef({ cwd: root, refName: "main" }); + + const change = yield* driver.currentChange(root); + assert.include(change.localBookmarks, "main"); + }), + ), + ); +}); + +describeJj("JjRefs.switchRef", () => { + it.effect("refuses to strand a commit the agent left on no bookmark", () => + withRepo({}, ({ fileSystem, path, refs, root }) => + Effect.gen(function* () { + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + yield* fileSystem.writeFileString(path.join(root, "agent.txt"), "agent\n"); + yield* runJj(root, ["commit", "-m", "agent work"]); + + const failure = yield* refs.switchRef({ cwd: root, refName: "main" }).pipe(Effect.flip); + + assert.include(failure.detail, "not on a bookmark yet"); + assert.include(failure.detail, "Create a bookmark for it"); + }), + ), + ); + + it.effect("tracks a remote ref before switching and records the Git upstream", () => + withRepo({ withRemote: true }, ({ fileSystem, refs, root }) => + Effect.gen(function* () { + yield* runJj(root, ["bookmark", "create", "feat/remote", "-r", "@-"]); + yield* runJj(root, ["git", "push", "--bookmark", "feat/remote", "--remote", "origin"]); + yield* runJj(root, ["bookmark", "forget", "feat/remote"]); + yield* runJj(root, ["git", "fetch", "--remote", "origin"]); + + const switched = yield* refs.switchRef({ cwd: root, refName: "origin/feat/remote" }); + + assert.equal(switched.refName, "feat/remote"); + const gitConfig = yield* fileSystem.readFileString(`${root}/.git/config`); + assert.include(gitConfig, 'branch "feat/remote"'); + assert.include(gitConfig, "refs/heads/feat/remote"); + }), + ), + ); +}); + +describeJj("JjRefs.renameBranch", () => { + it.effect("renames the bookmark and the workspace that carries it", () => + withRepo({}, ({ driver, refs, root, workspaces }) => + Effect.gen(function* () { + const created = yield* workspaces.createWorktree({ + cwd: root, + refName: "main", + newRefName: "feat/old", + path: null, + }); + + const renamed = yield* refs.renameBranch({ + cwd: created.worktree.path, + oldBranch: "feat/old", + newBranch: "feat/new", + }); + + assert.equal(renamed.branch, "feat/new"); + assert.include(yield* runJj(root, ["bookmark", "list"]), "feat/new"); + + // The bookmark-to-workspace mapping `listRefs` reports is keyed on the new name. + const workspaceNames = (yield* driver.listWorkspaces(root)).map( + (workspace) => workspace.name, + ); + assert.include(workspaceNames, workspaceNameForRef("feat/new")); + assert.notInclude(workspaceNames, workspaceNameForRef("feat/old")); + }), + ), + ); + + it.effect("leaves every workspace alone when the rename runs from the project root", () => + withRepo({}, ({ driver, refs, root, workspaces }) => + Effect.gen(function* () { + yield* workspaces.createWorktree({ + cwd: root, + refName: "main", + newRefName: "feat/elsewhere", + path: null, + }); + + yield* refs.renameBranch({ + cwd: root, + oldBranch: "feat/elsewhere", + newBranch: "feat/renamed", + }); + + // `jj workspace rename` renames whichever workspace `cwd` belongs to, so running it here + // would rename `default` and leave the thread's own workspace on the stale name. + const workspaceNames = (yield* driver.listWorkspaces(root)).map( + (workspace) => workspace.name, + ); + assert.include(workspaceNames, "default"); + assert.include(workspaceNames, workspaceNameForRef("feat/elsewhere")); + assert.notInclude(workspaceNames, workspaceNameForRef("feat/renamed")); + }), + ), + ); +}); diff --git a/apps/server/src/jj/JjRefs.ts b/apps/server/src/jj/JjRefs.ts new file mode 100644 index 000000000000..59d41c57c70f --- /dev/null +++ b/apps/server/src/jj/JjRefs.ts @@ -0,0 +1,396 @@ +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import type { GitCommandError, VcsRef } from "@t3tools/contracts"; +import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; + +import type { VcsWorkflowOps } from "../git/GitWorkflowService.ts"; +import { filterBranchesForListQuery, paginateBranches } from "../vcs/GitVcsDriverCore.ts"; +import * as JjProcess from "../vcs/JjProcess.ts"; +import { localBookmarkRevset, splitRemoteRefName } from "../vcs/JjRevset.ts"; +import type { + JjBookmark, + JjSegmentRow, + JjVcsDriverShape, + JjWorkspace, +} from "../vcs/JjVcsDriver.ts"; +import type * as VcsProcess from "../vcs/VcsProcess.ts"; +import { jjFailure, mapJjFailure } from "./JjFailure.ts"; +import { assertBookmarkUsable, trackRemoteBookmark } from "./JjRemotes.ts"; +import { resolveWorkspaceRefName, strandedSegmentRows } from "./JjStatus.ts"; +import { workspaceNameForRef } from "./JjWorkspaces.ts"; + +const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64; +const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); + +interface JjRefsSnapshot { + readonly bookmarks: ReadonlyArray; + readonly workspaces: ReadonlyArray; + readonly hasPrimaryRemote: boolean; + readonly defaultBookmark: string | null; +} + +export interface JjRefsDeps { + readonly driver: JjVcsDriverShape; + readonly process: VcsProcess.VcsProcess["Service"]; +} + +export type JjRefsOps = Pick< + VcsWorkflowOps, + "listRefs" | "createRef" | "switchRef" | "listLocalBranchNames" +> & { + readonly deleteLocalBranch: (input: { + readonly cwd: string; + readonly refName: string; + readonly force?: boolean; + }) => Effect.Effect; + readonly renameBranch: (input: { + readonly cwd: string; + readonly oldBranch: string; + readonly newBranch: string; + }) => Effect.Effect<{ readonly branch: string }, GitCommandError>; + /** Refuses when the segment holds work no bookmark protects. Shared with the PR-thread path. */ + readonly assertNoStrandedWork: (input: { + readonly cwd: string; + readonly operation: string; + readonly targetRefName: string; + }) => Effect.Effect; + readonly invalidateSnapshot: (cwd: string) => Effect.Effect; +}; + +export const makeJjRefs = (deps: JjRefsDeps): Effect.Effect => + Effect.gen(function* () { + const { driver, process } = deps; + + const run = JjProcess.jjRunner(process); + + const readSnapshot = Effect.fn("JjRefs.readSnapshot")(function* (mainWorkspaceRoot: string) { + const [bookmarks, workspaces, remoteNames, defaultBookmark] = yield* Effect.all([ + driver.listBookmarks(mainWorkspaceRoot), + driver.listWorkspaces(mainWorkspaceRoot), + driver.listRemoteNames(mainWorkspaceRoot), + driver.resolveDefaultBookmark(mainWorkspaceRoot), + ]); + return { + bookmarks, + workspaces, + hasPrimaryRemote: remoteNames.length > 0, + defaultBookmark, + } satisfies JjRefsSnapshot; + }); + + // Keyed on the main workspace root so every workspace of a repository shares one entry. + const snapshotCache = yield* Cache.makeWith(readSnapshot, { + capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: () => LIST_REFS_SNAPSHOT_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }); + + const repoPathsFor = (operation: string, cwd: string) => + driver + .ensureUsable(operation, cwd) + .pipe(mapJjFailure(operation, cwd, "This Jujutsu repository cannot be read.")); + + const invalidateSnapshot: JjRefsOps["invalidateSnapshot"] = (cwd) => + driver.repoPaths(cwd).pipe( + Effect.flatMap((paths) => Cache.invalidate(snapshotCache, paths.mainWorkspaceRoot)), + Effect.ignore, + ); + + const listRefs: JjRefsOps["listRefs"] = Effect.fn("JjRefs.listRefs")(function* (input) { + const operation = "JjRefs.listRefs"; + const paths = yield* repoPathsFor(operation, input.cwd); + if (input.refresh === true) { + yield* Cache.invalidate(snapshotCache, paths.mainWorkspaceRoot); + } + const snapshot = yield* Cache.get(snapshotCache, paths.mainWorkspaceRoot).pipe( + mapJjFailure(operation, input.cwd, "Could not list Jujutsu bookmarks."), + ); + const segment = yield* driver + .currentSegment(input.cwd) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const currentRefName = yield* resolveWorkspaceRefName(driver, input.cwd, segment); + + const workspaceRootByName = new Map( + snapshot.workspaces.map((workspace) => [workspace.name, workspace.root] as const), + ); + + const localRefs: ReadonlyArray = snapshot.bookmarks + .filter((bookmark) => bookmark.remote === null) + .toSorted((left, right) => right.targetTimestamp - left.targetTimestamp) + .map( + (bookmark) => + ({ + name: bookmark.name, + current: bookmark.name === currentRefName, + isDefault: bookmark.name === snapshot.defaultBookmark, + // A workspace jj lists with no root has lost its directory; reporting it would hand + // the client a path it passes straight back to `removeWorktree`. + worktreePath: workspaceRootByName.get(workspaceNameForRef(bookmark.name)) ?? null, + }) satisfies VcsRef, + ); + + const remoteRefs: ReadonlyArray = snapshot.bookmarks + .filter((bookmark) => bookmark.remote !== null) + .toSorted((left, right) => right.targetTimestamp - left.targetTimestamp) + .map( + (bookmark) => + ({ + name: `${bookmark.remote}/${bookmark.name}`, + isRemote: true, + ...(bookmark.remote === null ? {} : { remoteName: bookmark.remote }), + current: false, + isDefault: false, + worktreePath: null, + }) satisfies VcsRef, + ); + + const combined = + input.includeMatchingRemoteRefs === true + ? [...localRefs, ...remoteRefs] + : dedupeRemoteBranchesWithLocalMatches([...localRefs, ...remoteRefs]); + // Keep current/default refs on the first page, the way the Git implementation does. + const allRefs = combined.toSorted((left, right) => { + const leftPriority = left.current ? 0 : left.isDefault ? 1 : 2; + const rightPriority = right.current ? 0 : right.isDefault ? 1 : 2; + return leftPriority - rightPriority; + }); + const refsForKind = + input.refKind === "local" + ? allRefs.filter((ref) => !ref.isRemote) + : input.refKind === "remote" + ? allRefs.filter((ref) => ref.isRemote) + : allRefs; + + const page = paginateBranches({ + refs: filterBranchesForListQuery(refsForKind, input.query), + cursor: input.cursor, + limit: input.limit, + }); + + return { + refs: [...page.refs], + isRepo: true, + hasPrimaryRemote: snapshot.hasPrimaryRemote, + nextCursor: page.nextCursor, + totalCount: page.totalCount, + }; + }); + + const assertNoStrandedWork: JjRefsOps["assertNoStrandedWork"] = Effect.fn( + "JjRefs.assertNoStrandedWork", + )(function* (input) { + const change = yield* driver + .currentChange(input.cwd) + .pipe(mapJjFailure(input.operation, input.cwd, "Could not read the working-copy change.")); + const segment = yield* driver + .currentSegment(input.cwd) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const stranded = + strandedSegmentRows(segment).length > 0 || + (segment.length === 0 && !change.empty && change.localBookmarks.length === 0); + if (stranded) { + return yield* Effect.fail( + jjFailure( + input.operation, + input.cwd, + `This work is not on a bookmark yet. Create a bookmark for it (or commit and bookmark it) before switching to ${input.targetRefName}; \`jj new\` would hide it.`, + ), + ); + } + }); + + const createRef: JjRefsOps["createRef"] = Effect.fn("JjRefs.createRef")(function* (input) { + const operation = "JjRefs.createRef"; + yield* repoPathsFor(operation, input.cwd); + yield* assertBookmarkUsable(driver, operation, input.cwd, input.refName); + + const change = yield* driver + .currentChange(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not read the working-copy change.")); + // A non-empty `@` holds the work the user means to name. An empty `@` holds nothing, and it + // is exactly the state the agent leaves after `jj commit`, so the bookmark belongs on `@-`, + // except in a repository with no commits, where `@-` is the root commit and cannot export. + const target = !change.empty ? "@" : change.parentCommitIds.length > 0 ? "@-" : "@"; + + yield* run(operation, input.cwd, ["bookmark", "create", input.refName, "-r", target], { + timeoutMs: 20_000, + }).pipe(mapJjFailure(operation, input.cwd, `Could not create bookmark ${input.refName}.`)); + yield* JjProcess.exportGitRefs(process, operation, input.cwd).pipe( + mapJjFailure(operation, input.cwd, "Could not synchronize the bookmark with Git."), + ); + yield* invalidateSnapshot(input.cwd); + + // `switchRef` is a no-op after create: naming the current line of work is being on it, and + // `jj new` would move off the bookmark that was just made. + return { refName: input.refName }; + }); + + const switchRef: JjRefsOps["switchRef"] = Effect.fn("JjRefs.switchRef")(function* (input) { + const operation = "JjRefs.switchRef"; + const paths = yield* repoPathsFor(operation, input.cwd); + yield* assertNoStrandedWork({ + cwd: input.cwd, + operation, + targetRefName: input.refName, + }); + + const remoteNames = yield* driver + .listRemoteNames(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not read this repository's remotes.")); + const remoteRef = splitRemoteRefName(input.refName, remoteNames); + const localName = remoteRef?.name ?? input.refName; + + if (remoteRef !== null) { + yield* trackRemoteBookmark({ + process, + operation, + cwd: input.cwd, + gitDir: paths.gitDir, + remoteName: remoteRef.remote, + name: remoteRef.name, + }); + } + yield* assertBookmarkUsable(driver, operation, input.cwd, localName); + + // `jj new`, never `jj edit`: editing rewrites a possibly-pushed commit and walks straight + // into jj's immutability wall. + yield* run(operation, input.cwd, ["new", localBookmarkRevset(localName)], { + timeoutMs: 60_000, + }).pipe(mapJjFailure(operation, input.cwd, `Could not switch to ${localName}.`)); + yield* invalidateSnapshot(input.cwd); + + return { refName: localName }; + }); + + const renameBranch: JjRefsOps["renameBranch"] = Effect.fn("JjRefs.renameBranch")( + function* (input) { + const operation = "JjRefs.renameBranch"; + const paths = yield* repoPathsFor(operation, input.cwd); + yield* assertBookmarkUsable(driver, operation, input.cwd, input.oldBranch); + + yield* run(operation, input.cwd, ["bookmark", "rename", input.oldBranch, input.newBranch], { + timeoutMs: 20_000, + }).pipe( + mapJjFailure( + operation, + input.cwd, + `Could not rename bookmark ${input.oldBranch} to ${input.newBranch}.`, + ), + ); + + // Without the workspace rename the bookmark-to-workspace mapping `listRefs` reports breaks + // for this thread. `jj workspace rename` renames the workspace `cwd` belongs to and takes no + // name, so it may only run when that workspace is the one the old bookmark named. From the + // project root it would rename `default`. + const workspaces = yield* driver + .listWorkspaces(input.cwd) + .pipe(Effect.orElseSucceed(() => [])); + const currentWorkspace = workspaces.find( + (workspace) => workspace.root === paths.workspaceRoot, + ); + if (currentWorkspace?.name === workspaceNameForRef(input.oldBranch)) { + yield* run( + operation, + input.cwd, + ["workspace", "rename", workspaceNameForRef(input.newBranch)], + { allowNonZeroExit: true, timeoutMs: 20_000 }, + ).pipe(Effect.ignore); + } + + yield* moveBookmarkGitConfig(process, { + gitDir: paths.gitDir, + cwd: input.cwd, + oldName: input.oldBranch, + newName: input.newBranch, + }); + yield* JjProcess.exportGitRefs(process, operation, input.cwd).pipe( + mapJjFailure( + operation, + input.cwd, + "Could not synchronize the renamed bookmark with Git.", + ), + ); + yield* invalidateSnapshot(input.cwd); + + return { branch: input.newBranch }; + }, + ); + + const listLocalBranchNames: JjRefsOps["listLocalBranchNames"] = Effect.fn( + "JjRefs.listLocalBranchNames", + )(function* (cwd: string) { + const operation = "JjRefs.listLocalBranchNames"; + yield* repoPathsFor(operation, cwd); + const bookmarks = yield* driver + .listBookmarks(cwd) + .pipe(mapJjFailure(operation, cwd, "Could not read this repository's bookmarks.")); + return bookmarks + .filter((bookmark) => bookmark.remote === null) + .map((bookmark) => bookmark.name); + }); + + // jj has no force flag here: deleting a bookmark never discards the commits it pointed at. + const deleteLocalBranch: JjRefsOps["deleteLocalBranch"] = Effect.fn("JjRefs.deleteLocalBranch")( + function* (input) { + const operation = "JjRefs.deleteLocalBranch"; + yield* repoPathsFor(operation, input.cwd); + yield* run(operation, input.cwd, ["bookmark", "delete", input.refName], { + timeoutMs: 20_000, + }).pipe(mapJjFailure(operation, input.cwd, `Could not delete bookmark ${input.refName}.`)); + yield* invalidateSnapshot(input.cwd); + }, + ); + + return { + listRefs, + createRef, + switchRef, + renameBranch, + listLocalBranchNames, + deleteLocalBranch, + assertNoStrandedWork, + invalidateSnapshot, + }; + }); + +/** Carries the thread's PR base and upstream across a rename; jj itself ignores git branch config. */ +const moveBookmarkGitConfig = Effect.fn("JjRefs.moveBookmarkGitConfig")(function* ( + process: VcsProcess.VcsProcess["Service"], + input: { + readonly gitDir: string | null; + readonly cwd: string; + readonly oldName: string; + readonly newName: string; + }, +): Effect.fn.Return { + const gitDir = input.gitDir; + if (gitDir === null) { + return; + } + const git = (args: ReadonlyArray) => + JjProcess.colocatedGitCommand( + process, + "JjRefs.moveBookmarkGitConfig", + { gitDir, cwd: input.cwd }, + args, + { allowNonZeroExit: true, timeoutMs: 10_000 }, + ); + + for (const key of ["remote", "merge", "gh-merge-base"] as const) { + const existing = yield* git(["config", "--get", `branch.${input.oldName}.${key}`]).pipe( + Effect.map((result) => (result.exitCode === 0 ? result.stdout.trim() : "")), + Effect.orElseSucceed(() => ""), + ); + if (existing.length === 0) { + continue; + } + const value = key === "merge" ? `refs/heads/${input.newName}` : existing; + yield* git(["config", `branch.${input.newName}.${key}`, value]).pipe(Effect.ignore); + yield* git(["config", "--unset", `branch.${input.oldName}.${key}`]).pipe(Effect.ignore); + } +}); diff --git a/apps/server/src/jj/JjRemotes.test.ts b/apps/server/src/jj/JjRemotes.test.ts new file mode 100644 index 000000000000..6255a4aaaf06 --- /dev/null +++ b/apps/server/src/jj/JjRemotes.test.ts @@ -0,0 +1,193 @@ +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 JjVcsDriver from "../vcs/JjVcsDriver.ts"; +import { describeJj, runJj, withJjRepo } from "../vcs/testing/JjTestSupport.ts"; +import { makeJjRemotes, type JjRemoteOps } from "./JjRemotes.ts"; + +interface RemotesFixture { + readonly driver: JjVcsDriver.JjVcsDriverShape; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly remotes: JjRemoteOps; + readonly root: string; + /** A second checkout of the same remote, for moving it behind the first one's back. */ + readonly peer: string; +} + +const withRemote = ( + use: (fixture: RemotesFixture) => Effect.Effect, +) => + withJjRepo({ prefix: "t3-jj-remotes-", withRemote: true }, (repo) => + Effect.gen(function* () { + const { base, driver, fileSystem, path, process, remotePath, root } = repo; + const peer = path.join(base, "peer"); + yield* fileSystem.makeDirectory(peer, { recursive: true }); + yield* runJj(peer, ["git", "clone", "--colocate", remotePath, peer]); + + const remotes = makeJjRemotes({ driver, process }); + + return yield* use({ driver, fileSystem, path, remotes, root, peer }); + }), + ); + +describeJj("JjRemotes.bookmarkTo", () => { + it.effect("moves forward, no-ops when already there, and refuses a sideways move", () => + withRemote(({ driver, fileSystem, path, remotes, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "one.txt"), "one\n"); + yield* runJj(root, ["commit", "-m", "one"]); + + yield* remotes.bookmarkTo({ + cwd: root, + name: "main", + revset: "@-", + operation: "test", + }); + const moved = yield* driver.changeAt(root, 'bookmarks(exact:"main")'); + const parent = yield* driver.changeAt(root, "@-"); + assert.equal(moved?.commitId, parent?.commitId); + + // Idempotent: a re-run of the same move changes nothing and does not fail. + yield* remotes.bookmarkTo({ + cwd: root, + name: "main", + revset: "@-", + operation: "test", + }); + + // A sibling is not an ancestor, so the guard refuses instead of letting jj refuse rawly. + yield* runJj(root, ["new", 'bookmarks(exact:"main")-', "-m", "sibling"]); + const failure = yield* remotes + .bookmarkTo({ cwd: root, name: "main", revset: "@", operation: "test" }) + .pipe(Effect.flip); + assert.include(failure.detail, "not an ancestor of this one"); + }), + ), + ); +}); + +describeJj("JjRemotes.pushBookmark", () => { + it.effect("pushes, then reports the second push as up to date", () => + withRemote(({ fileSystem, path, remotes, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "one.txt"), "one\n"); + yield* runJj(root, ["commit", "-m", "one"]); + + const first = yield* remotes.pushBookmark({ + cwd: root, + name: "main", + remoteName: "origin", + operation: "test", + }); + assert.equal(first.status, "pushed"); + + const second = yield* remotes.pushBookmark({ + cwd: root, + name: "main", + remoteName: "origin", + operation: "test", + }); + assert.equal(second.status, "skipped_up_to_date"); + }), + ), + ); + + it.effect("refuses a bookmark that points at the working copy", () => + withRemote(({ fileSystem, path, remotes, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "editing.txt"), "editing\n"); + yield* runJj(root, ["bookmark", "create", "feat/editing", "-r", "@"]); + + const failure = yield* remotes + .pushBookmark({ + cwd: root, + name: "feat/editing", + remoteName: "origin", + operation: "test", + }) + .pipe(Effect.flip); + + assert.include(failure.detail, "points at your working copy"); + }), + ), + ); + + it.effect("asks for a description instead of publishing an undescribed change", () => + withRemote(({ fileSystem, path, remotes, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "one.txt"), "one\n"); + yield* runJj(root, ["new"]); + yield* runJj(root, ["bookmark", "create", "feat/undescribed", "-r", "@-"]); + + const failure = yield* remotes + .pushBookmark({ + cwd: root, + name: "feat/undescribed", + remoteName: "origin", + operation: "test", + }) + .pipe(Effect.flip); + + assert.include(failure.detail, "Describe this change before pushing."); + }), + ), + ); +}); + +describeJj("JjRemotes.pullCurrentBranch", () => { + it.effect("reports an up-to-date bookmark without touching the working copy", () => + withRemote(({ remotes, root }) => + Effect.gen(function* () { + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + const pulled = yield* remotes.pullCurrentBranch(root); + + assert.equal(pulled.status, "skipped_up_to_date"); + assert.equal(pulled.refName, "main"); + }), + ), + ); + + it.effect("fast-forwards onto the remote bookmark", () => + withRemote(({ driver, fileSystem, path, peer, remotes, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(peer, "peer.txt"), "peer\n"); + yield* runJj(peer, ["commit", "-m", "peer work"]); + yield* runJj(peer, ["bookmark", "set", "main", "-r", "@-"]); + yield* runJj(peer, ["git", "push", "--bookmark", "main", "--remote", "origin"]); + + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + const pulled = yield* remotes.pullCurrentBranch(root); + + assert.equal(pulled.status, "pulled"); + assert.equal(pulled.upstreamRef, "origin/main"); + assert.isTrue(yield* fileSystem.exists(path.join(root, "peer.txt"))); + const parent = yield* driver.changeAt(root, "@-"); + assert.include(parent?.localBookmarks ?? [], "main"); + }), + ), + ); + + it.effect("names the rebase instead of pulling over local work", () => + withRemote(({ fileSystem, path, peer, remotes, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(peer, "peer.txt"), "peer\n"); + yield* runJj(peer, ["commit", "-m", "peer work"]); + yield* runJj(peer, ["bookmark", "set", "main", "-r", "@-"]); + yield* runJj(peer, ["git", "push", "--bookmark", "main", "--remote", "origin"]); + + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + yield* fileSystem.writeFileString(path.join(root, "local.txt"), "local\n"); + yield* runJj(root, ["commit", "-m", "local work"]); + yield* runJj(root, ["bookmark", "set", "main", "-r", "@-"]); + + const failure = yield* remotes.pullCurrentBranch(root).pipe(Effect.flip); + + assert.include(failure.detail, "has local changes"); + assert.include(failure.detail, "Rebase onto origin/main"); + }), + ), + ); +}); diff --git a/apps/server/src/jj/JjRemotes.ts b/apps/server/src/jj/JjRemotes.ts new file mode 100644 index 000000000000..a046e4af5346 --- /dev/null +++ b/apps/server/src/jj/JjRemotes.ts @@ -0,0 +1,526 @@ +import * as Effect from "effect/Effect"; + +import { GitCommandError, type VcsPullResult } from "@t3tools/contracts"; +import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; + +import type { VcsWorkflowOps } from "../git/GitWorkflowService.ts"; +import * as JjProcess from "../vcs/JjProcess.ts"; +import { localBookmarkRevset, remoteBookmarkRevset, splitRemoteRefName } from "../vcs/JjRevset.ts"; +import type { JjVcsDriverShape } from "../vcs/JjVcsDriver.ts"; +import type * as VcsProcess from "../vcs/VcsProcess.ts"; +import { jjFailure, mapJjFailure } from "./JjFailure.ts"; +import { + computeAheadBehindCounts, + resolveWorkspaceRefName, + resolveUpstreamContext, +} from "./JjStatus.ts"; + +const FETCH_TIMEOUT_MS = 60_000; +const PUSH_TIMEOUT_MS = 120_000; + +/** jj stderr is the only place the real reason lives; bound it the way the git path bounds its own. */ +const STDERR_DETAIL_MAX_CHARS = 500; + +function boundedStderr(stderr: string): string { + return stderr.trim().slice(0, STDERR_DETAIL_MAX_CHARS); +} + +/** + * A conflicted bookmark cannot be used as a revset symbol, and `jj bookmark set` on one silently + * resolves the conflict by discarding a side. The guard lives beside `bookmarkTo`, its most + * dangerous caller, so no call site can forget it. + */ +export const assertBookmarkUsable = Effect.fn("JjRemotes.assertBookmarkUsable")(function* ( + driver: JjVcsDriverShape, + operation: string, + cwd: string, + name: string, +): Effect.fn.Return { + const bookmarks = yield* driver + .listBookmarks(cwd) + .pipe(mapJjFailure(operation, cwd, "Could not list Jujutsu bookmarks.")); + const conflicted = bookmarks.some( + (bookmark) => bookmark.name === name && bookmark.remote === null && bookmark.conflict, + ); + if (conflicted) { + return yield* Effect.fail( + jjFailure( + operation, + cwd, + `Bookmark ${name} is conflicted. Resolve it with \`jj bookmark set ${name} -r \` before using it here.`, + ), + ); + } +}); + +/** + * jj keeps no upstream metadata in git's branch config, so the git-side pull-request machinery + * never probes owner-qualified heads and misses fork PRs. These two keys are inert to jj and make + * it behave exactly as it does for a git checkout. + */ +export const writeBookmarkUpstreamConfig = Effect.fn("JjRemotes.writeBookmarkUpstreamConfig")( + function* ( + process: VcsProcess.VcsProcess["Service"], + input: { + readonly gitDir: string | null; + readonly cwd: string; + readonly name: string; + readonly remoteName: string; + }, + ): Effect.fn.Return { + const gitDir = input.gitDir; + if (gitDir === null) { + return; + } + const write = (key: string, value: string) => + JjProcess.colocatedGitCommand( + process, + "JjRemotes.writeBookmarkUpstreamConfig", + { gitDir, cwd: input.cwd }, + ["config", key, value], + { allowNonZeroExit: true, timeoutMs: 10_000 }, + ).pipe(Effect.ignore); + + yield* write(`branch.${input.name}.remote`, input.remoteName); + yield* write(`branch.${input.name}.merge`, `refs/heads/${input.name}`); + }, +); + +/** `jj bookmark track --remote=`, plus the git upstream config the hosting side reads. */ +export const trackRemoteBookmark = Effect.fn("JjRemotes.trackRemoteBookmark")(function* (input: { + readonly process: VcsProcess.VcsProcess["Service"]; + readonly operation: string; + readonly cwd: string; + readonly gitDir: string | null; + readonly remoteName: string; + readonly name: string; +}): Effect.fn.Return { + // The `@` argument form is deprecated in 0.42 and prints a warning on every call. + yield* JjProcess.jjCommand( + input.process, + input.operation, + input.cwd, + ["bookmark", "track", input.name, `--remote=${input.remoteName}`], + { allowNonZeroExit: true, timeoutMs: 20_000 }, + ).pipe( + mapJjFailure(input.operation, input.cwd, `Could not track ${input.remoteName}/${input.name}.`), + ); + + yield* writeBookmarkUpstreamConfig(input.process, { + gitDir: input.gitDir, + cwd: input.cwd, + name: input.name, + remoteName: input.remoteName, + }); +}); + +export interface JjRemoteOpsDeps { + readonly driver: JjVcsDriverShape; + readonly process: VcsProcess.VcsProcess["Service"]; +} + +export interface JjBookmarkMoveInput { + readonly cwd: string; + readonly name: string; + readonly revset: string; + readonly operation: string; +} + +export interface JjPublishResult { + readonly refName: string; + /** + * `remote_added` mirrors the Git arm's partial success for a repository with no commits: the + * remote is wired up and there is simply nothing to publish yet. + */ + readonly status: "pushed" | "remote_added"; +} + +export interface JjPushResult { + readonly status: "pushed" | "skipped_up_to_date"; + readonly refName: string; +} + +export type JjRemoteOps = Pick< + VcsWorkflowOps, + "fetchRemote" | "remoteExists" | "remoteBranchExists" | "resolveRemoteTrackingCommit" +> & { + readonly requirePrimaryRemoteName: ( + operation: string, + cwd: string, + ) => Effect.Effect; + /** Forward-only. `jj bookmark set` refuses a backwards or sideways move, so the guard runs first. */ + readonly bookmarkTo: (input: JjBookmarkMoveInput) => Effect.Effect; + readonly pushBookmark: (input: { + readonly cwd: string; + readonly name: string; + readonly remoteName: string; + readonly operation: string; + }) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly publishRepository: (input: { + readonly cwd: string; + readonly remoteName: string; + readonly remoteUrl: string; + }) => Effect.Effect; +}; + +export const makeJjRemotes = (deps: JjRemoteOpsDeps): JjRemoteOps => { + const { driver, process } = deps; + + const run = JjProcess.jjRunner(process); + + const requirePrimaryRemoteName = Effect.fn("JjRemotes.requirePrimaryRemoteName")(function* ( + operation: string, + cwd: string, + ): Effect.fn.Return { + const remoteName = yield* driver + .resolvePrimaryRemoteName(cwd) + .pipe(mapJjFailure(operation, cwd, "Could not read this repository's remotes.")); + if (remoteName === null) { + return yield* Effect.fail( + jjFailure(operation, cwd, "Cannot resolve a remote for this repository."), + ); + } + return remoteName; + }); + + const fetchRemote: JjRemoteOps["fetchRemote"] = Effect.fn("JjRemotes.fetchRemote")( + function* (input) { + const bookmark = + input.refName === undefined + ? undefined + : (splitRemoteRefName(input.refName, [input.remoteName])?.name ?? input.refName); + yield* run( + "JjRemotes.fetchRemote", + input.cwd, + [ + "git", + "fetch", + "--remote", + input.remoteName, + ...(bookmark === undefined ? [] : ["--branch", bookmark]), + ], + { timeoutMs: FETCH_TIMEOUT_MS }, + ).pipe( + mapJjFailure( + "JjRemotes.fetchRemote", + input.cwd, + `Could not fetch from ${input.remoteName}.`, + ), + ); + yield* JjProcess.exportGitRefs(process, "JjRemotes.fetchRemote", input.cwd).pipe( + mapJjFailure( + "JjRemotes.fetchRemote", + input.cwd, + "Could not synchronize fetched refs with Git.", + ), + ); + }, + ); + + const remoteExists: JjRemoteOps["remoteExists"] = (input) => + driver.listRemoteNames(input.cwd).pipe( + Effect.map((names) => names.includes(input.remoteName)), + mapJjFailure( + "JjRemotes.remoteExists", + input.cwd, + "Could not read this repository's remotes.", + ), + ); + + const remoteBranchExists: JjRemoteOps["remoteBranchExists"] = (input) => + driver.listBookmarks(input.cwd).pipe( + Effect.map((bookmarks) => + bookmarks.some( + (bookmark) => bookmark.name === input.refName && bookmark.remote === input.remoteName, + ), + ), + mapJjFailure("JjRemotes.remoteBranchExists", input.cwd, "Could not list Jujutsu bookmarks."), + ); + + const resolveRemoteTrackingCommit: JjRemoteOps["resolveRemoteTrackingCommit"] = Effect.fn( + "JjRemotes.resolveRemoteTrackingCommit", + )(function* (input) { + const operation = "JjRemotes.resolveRemoteTrackingCommit"; + const remoteNames = yield* driver + .listRemoteNames(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not read this repository's remotes.")); + const split = splitRemoteRefName(input.refName, remoteNames); + const remoteName = split?.remote ?? input.fallbackRemoteName; + const name = split?.name ?? input.refName; + + const change = yield* driver.changeAt(input.cwd, remoteBookmarkRevset(remoteName, name)).pipe( + Effect.orElseSucceed(() => null), + mapJjFailure(operation, input.cwd, "Could not resolve the remote bookmark."), + ); + if (change === null) { + return yield* Effect.fail( + jjFailure(operation, input.cwd, `${remoteName}/${name} does not exist on the remote.`), + ); + } + return { commitSha: change.commitId, remoteRefName: `${remoteName}/${name}` }; + }); + + const bookmarkTo: JjRemoteOps["bookmarkTo"] = Effect.fn("JjRemotes.bookmarkTo")( + function* (input) { + yield* assertBookmarkUsable(driver, input.operation, input.cwd, input.name); + + const bookmarks = yield* driver + .listBookmarks(input.cwd) + .pipe(mapJjFailure(input.operation, input.cwd, "Could not list Jujutsu bookmarks.")); + const existing = bookmarks.find( + (bookmark) => bookmark.name === input.name && bookmark.remote === null, + ); + + if (existing === undefined) { + yield* run( + input.operation, + input.cwd, + ["bookmark", "create", input.name, "-r", input.revset], + { timeoutMs: 20_000 }, + ).pipe( + mapJjFailure(input.operation, input.cwd, `Could not create bookmark ${input.name}.`), + ); + yield* JjProcess.exportGitRefs(process, input.operation, input.cwd).pipe( + mapJjFailure(input.operation, input.cwd, "Could not synchronize the bookmark with Git."), + ); + return; + } + + const target = yield* driver + .changeAt(input.cwd, input.revset) + .pipe(mapJjFailure(input.operation, input.cwd, "Could not resolve the target revision.")); + if (target !== null && existing.target === target.commitId) { + return; + } + + // An ancestry test, not a range: `A..B` is non-empty for siblings too, so a range would + // authorise a sideways move and leave jj's raw refusal to reach the user. + const isDescendant = yield* driver + .countRevset(input.cwd, `${localBookmarkRevset(input.name)} & ::(${input.revset})`) + .pipe(mapJjFailure(input.operation, input.cwd, "Could not compare bookmark ancestry.")); + if (isDescendant === 0) { + return yield* Effect.fail( + jjFailure( + input.operation, + input.cwd, + `Bookmark ${input.name} points at a change that is not an ancestor of this one. Move it in jj (\`jj bookmark set ${input.name} -r @ --allow-backwards\`) if that is what you want.`, + ), + ); + } + + yield* run(input.operation, input.cwd, ["bookmark", "set", input.name, "-r", input.revset], { + timeoutMs: 20_000, + }).pipe(mapJjFailure(input.operation, input.cwd, `Could not move bookmark ${input.name}.`)); + yield* JjProcess.exportGitRefs(process, input.operation, input.cwd).pipe( + mapJjFailure(input.operation, input.cwd, "Could not synchronize the bookmark with Git."), + ); + }, + ); + + const pushBookmark: JjRemoteOps["pushBookmark"] = Effect.fn("JjRemotes.pushBookmark")( + function* (input) { + const change = yield* driver + .currentChange(input.cwd) + .pipe(mapJjFailure(input.operation, input.cwd, "Could not read the working-copy change.")); + if (change.conflict) { + return yield* Effect.fail( + jjFailure(input.operation, input.cwd, "Resolve conflicts in this change before pushing."), + ); + } + + const bookmarks = yield* driver + .listBookmarks(input.cwd) + .pipe(mapJjFailure(input.operation, input.cwd, "Could not list Jujutsu bookmarks.")); + const existing = bookmarks.find( + (bookmark) => bookmark.name === input.name && bookmark.remote === null, + ); + // A bookmark that sits on `@` follows every snapshot, and jj accepts the push, so a bare Push + // would publish whatever is in the editor. Moving it for them is worse than refusing. + if (existing !== undefined && existing.target === change.commitId) { + return yield* Effect.fail( + jjFailure( + input.operation, + input.cwd, + `Bookmark ${input.name} points at your working copy. Commit this change before pushing it.`, + ), + ); + } + + if (change.parentCommitIds.length > 0) { + yield* bookmarkTo({ + cwd: input.cwd, + name: input.name, + revset: "@-", + operation: input.operation, + }); + } + + // Compared, not parsed: `--quiet` suppresses jj's own "Nothing changed." line, and the remote + // row already records what the remote holds. + const moved = yield* driver + .listBookmarks(input.cwd) + .pipe(mapJjFailure(input.operation, input.cwd, "Could not list Jujutsu bookmarks.")); + const localTarget = moved.find( + (bookmark) => bookmark.name === input.name && bookmark.remote === null, + )?.target; + const remoteTarget = moved.find( + (bookmark) => bookmark.name === input.name && bookmark.remote === input.remoteName, + )?.target; + if (localTarget !== undefined && localTarget !== null && localTarget === remoteTarget) { + return { status: "skipped_up_to_date" as const, refName: input.name }; + } + + const result = yield* run( + input.operation, + input.cwd, + ["git", "push", "--bookmark", input.name, "--remote", input.remoteName], + { allowNonZeroExit: true, timeoutMs: PUSH_TIMEOUT_MS }, + ).pipe(mapJjFailure(input.operation, input.cwd, `Could not push ${input.name}.`)); + + if (result.exitCode !== 0) { + const stderr = boundedStderr(result.stderr); + // `--allow-empty-description` is never passed on the user's behalf: an undescribed commit + // reaching a remote is a decision, not a default. + const detail = stderr.toLowerCase().includes("no description") + ? "Describe this change before pushing." + : stderr || `Could not push ${input.name}.`; + return yield* Effect.fail(jjFailure(input.operation, input.cwd, detail)); + } + + yield* JjProcess.exportGitRefs(process, input.operation, input.cwd).pipe( + mapJjFailure( + input.operation, + input.cwd, + "The bookmark was pushed, but Git ref synchronization failed.", + ), + ); + return { status: "pushed" as const, refName: input.name }; + }, + ); + + const pullCurrentBranch: JjRemoteOps["pullCurrentBranch"] = Effect.fn( + "JjRemotes.pullCurrentBranch", + )(function* (cwd) { + const operation = "JjRemotes.pullCurrentBranch"; + const remoteName = yield* requirePrimaryRemoteName(operation, cwd); + + // Read the bookmark before fetching: jj fast-forwards a tracked bookmark during the fetch + // itself, and `@` is then no longer a descendant of it. + const segment = yield* driver + .currentSegment(cwd) + .pipe(mapJjFailure(operation, cwd, "Could not read the current bookmark.")); + const refName = yield* resolveWorkspaceRefName(driver, cwd, segment); + if (refName === null) { + return yield* Effect.fail( + jjFailure( + operation, + cwd, + "This workspace is not on a bookmark, so there is nothing to pull.", + ), + ); + } + + yield* fetchRemote({ cwd, remoteName }); + + const upstreamRef = `${remoteName}/${refName}`; + const upstream = yield* resolveUpstreamContext(driver, cwd, refName); + + const counts = yield* computeAheadBehindCounts(driver, cwd, { + refName, + primaryRemote: remoteName, + defaultBookmark: upstream.defaultBookmark, + hasUpstream: upstream.hasUpstream, + }); + if (counts.behindCount === 0) { + return { status: "skipped_up_to_date" as const, refName, upstreamRef }; + } + + const change = yield* driver + .currentChange(cwd) + .pipe(mapJjFailure(operation, cwd, "Could not read the working-copy change.")); + if (counts.aheadCount > 0 || !change.empty) { + return yield* Effect.fail( + jjFailure( + operation, + cwd, + `${refName} has local changes. Rebase onto ${upstreamRef} in jj instead of pulling.`, + ), + ); + } + + // A true fast-forward: the ancestry check inside `bookmarkTo` is exactly the ff-only test. + yield* bookmarkTo({ + cwd, + name: refName, + revset: remoteBookmarkRevset(remoteName, refName), + operation, + }); + yield* run(operation, cwd, ["new", localBookmarkRevset(refName)], { + timeoutMs: 60_000, + }).pipe(mapJjFailure(operation, cwd, `Could not move onto ${refName}.`)); + + return { status: "pulled" as const, refName, upstreamRef }; + }); + + const publishRepository: JjRemoteOps["publishRepository"] = Effect.fn( + "JjRemotes.publishRepository", + )(function* (input) { + const operation = "JjRemotes.publishRepository"; + const existingRemotes = yield* driver + .listRemoteNames(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not read this repository's remotes.")); + + yield* run( + operation, + input.cwd, + existingRemotes.includes(input.remoteName) + ? ["git", "remote", "set-url", input.remoteName, input.remoteUrl] + : ["git", "remote", "add", input.remoteName, input.remoteUrl], + { timeoutMs: 20_000 }, + ).pipe(mapJjFailure(operation, input.cwd, `Could not configure remote ${input.remoteName}.`)); + yield* driver.invalidateRepoCaches(input.cwd); + + const segment = yield* driver + .currentSegment(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not read the current bookmark.")); + const bookmarks = yield* driver + .listBookmarks(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not list Jujutsu bookmarks.")); + const refName = + (yield* resolveWorkspaceRefName(driver, input.cwd, segment)) ?? + (yield* driver.resolveDefaultBookmark(input.cwd).pipe(Effect.orElseSucceed(() => null))) ?? + resolveAutoFeatureBranchName( + bookmarks.filter((bookmark) => bookmark.remote === null).map((bookmark) => bookmark.name), + "main", + ); + + const change = yield* driver + .currentChange(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not read the working-copy change.")); + if (change.parentCommitIds.length === 0) { + return { refName, status: "remote_added" as const }; + } + + yield* bookmarkTo({ cwd: input.cwd, name: refName, revset: "@-", operation }); + yield* pushBookmark({ + cwd: input.cwd, + name: refName, + remoteName: input.remoteName, + operation, + }); + return { refName, status: "pushed" as const }; + }); + + return { + fetchRemote, + remoteExists, + remoteBranchExists, + resolveRemoteTrackingCommit, + requirePrimaryRemoteName, + bookmarkTo, + pushBookmark, + pullCurrentBranch, + publishRepository, + }; +}; diff --git a/apps/server/src/jj/JjStackedAction.test.ts b/apps/server/src/jj/JjStackedAction.test.ts new file mode 100644 index 000000000000..a1810626ee99 --- /dev/null +++ b/apps/server/src/jj/JjStackedAction.test.ts @@ -0,0 +1,417 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { + GitCommandError, + TextGenerationError, + type GitActionProgressEvent, + type GitRunStackedActionInput, +} from "@t3tools/contracts"; + +import type { ChangeRequestStepServices, ChangeRequestVcsReads } from "../git/GitManager.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as JjProcess from "../vcs/JjProcess.ts"; +import * as JjVcsDriver from "../vcs/JjVcsDriver.ts"; +import { + describeJj, + JjDriverLayer, + runJj, + seedJjRepo, + stubSourceControlProviders, +} from "../vcs/testing/JjTestSupport.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { makeJjRefs } from "./JjRefs.ts"; +import { makeJjRemotes } from "./JjRemotes.ts"; +import { makeJjStackedAction, type JjStackedActionOps } from "./JjStackedAction.ts"; + +const textGeneration: TextGeneration.TextGeneration["Service"] = { + generateCommitMessage: (input) => + Effect.succeed({ + subject: "Implement the change", + body: "", + ...(input.includeBranch ? { branch: "feature/implement-the-change" } : {}), + }), + generatePrContent: () => + Effect.fail( + new TextGenerationError({ operation: "generatePrContent", detail: "not exercised" }), + ), + generateBranchName: () => Effect.succeed({ branch: "feature/implement-the-change" }), + generateThreadTitle: () => Effect.succeed({ title: "Implement the change" }), +}; + +const ServicesLayer = Layer.mergeAll( + Layer.succeed(TextGeneration.TextGeneration, textGeneration), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + ServerSettings.layerTest(), + Layer.effect( + SourceControlProviderRegistry.SourceControlProviderRegistry, + stubSourceControlProviders, + ), +); + +interface ActionFixture { + readonly driver: JjVcsDriver.JjVcsDriverShape; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly actions: JjStackedActionOps; + readonly createRef: (cwd: string, refName: string) => Effect.Effect; + readonly events: Array; + /** Every process invocation the action made, so the built argv can be asserted directly. */ + readonly runs: Array; + readonly root: string; +} + +const runInput = (input: Partial): GitRunStackedActionInput => ({ + actionId: "action-1", + cwd: input.cwd ?? "", + action: input.action ?? "commit", + ...(input.commitMessage !== undefined ? { commitMessage: input.commitMessage } : {}), + ...(input.featureBranch !== undefined ? { featureBranch: input.featureBranch } : {}), + ...(input.filePaths !== undefined ? { filePaths: input.filePaths } : {}), +}); + +const withRepo = ( + use: ( + fixture: ActionFixture, + ) => Effect.Effect, +) => + Effect.gen(function* () { + const { root } = yield* seedJjRepo({ + prefix: "t3-jj-action-", + withRemote: true, + }); + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + + const driver = yield* JjVcsDriver.JjVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const process = yield* VcsProcess.VcsProcess; + + const events: Array = []; + const runs: Array = []; + const recordingProcess: VcsProcess.VcsProcess["Service"] = { + run: (processInput) => + Effect.suspend(() => { + runs.push(processInput); + return process.run(processInput); + }), + }; + // Every jj call the action makes, its bookmark moves included, goes through the recorder. + const remotes = makeJjRemotes({ driver, process: recordingProcess }); + const refs = yield* makeJjRefs({ driver, process }); + + const executeGit: Parameters[0]["executeGit"] = (gitInput) => + driver.repoPaths(gitInput.cwd).pipe( + Effect.flatMap((paths) => + JjProcess.colocatedGitCommand( + process, + gitInput.operation, + { gitDir: paths.gitDir ?? "", cwd: gitInput.cwd }, + gitInput.args, + { allowNonZeroExit: true }, + ), + ), + Effect.mapError( + (cause) => + new GitCommandError({ + operation: gitInput.operation, + command: "git", + cwd: gitInput.cwd, + detail: "colocated git failed", + cause, + }), + ), + ); + + const actions = makeJjStackedAction({ + driver, + executeGit, + process: recordingProcess, + remotes, + sourceControlProviders: yield* stubSourceControlProviders, + changeRequestReads: (cwd) => + Effect.fail( + new GitCommandError({ + operation: "test", + command: "jj", + cwd, + detail: "the pull-request phase is not exercised here", + }), + ) as Effect.Effect, + invalidateStatus: () => Effect.void, + }); + + return yield* use({ + driver, + fileSystem, + path, + actions, + createRef: (cwd, refName) => refs.createRef({ cwd, refName }), + events, + runs, + root, + }); + }).pipe(Effect.provide(Layer.mergeAll(JjDriverLayer, ServicesLayer))); + +const reporter = (events: Array) => ({ + progressReporter: { + publish: (event: GitActionProgressEvent) => + Effect.sync(() => { + events.push(event); + }), + }, +}); + +describeJj("JjStackedAction commit phase", () => { + it.effect("describes `@`, moves the bookmark to `@-`, and emits no hook events", () => + withRepo(({ actions, driver, events, fileSystem, path, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "work.txt"), "work\n"); + + const result = yield* actions.runStackedAction( + runInput({ cwd: root, action: "commit" }), + reporter(events), + ); + + assert.equal(result.commit.status, "created"); + const parent = yield* driver.changeAt(root, "@-"); + assert.include(parent?.description ?? "", "Implement the change"); + assert.include(parent?.localBookmarks ?? [], "main"); + assert.isFalse(events.some((event) => event.kind.startsWith("hook_"))); + }), + ), + ); + + it.effect("skips an empty change with nothing committed below it", () => + withRepo(({ actions, events, root }) => + Effect.gen(function* () { + const result = yield* actions.runStackedAction( + runInput({ cwd: root, action: "commit" }), + reporter(events), + ); + + assert.equal(result.commit.status, "skipped_no_changes"); + }), + ), + ); + + it.effect("moves the bookmark when the agent already committed the work itself", () => + withRepo(({ actions, driver, events, fileSystem, path, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "agent.txt"), "agent\n"); + yield* runJj(root, ["commit", "-m", "agent work"]); + + const result = yield* actions.runStackedAction( + runInput({ cwd: root, action: "commit_push" }), + reporter(events), + ); + + assert.equal(result.commit.status, "created"); + assert.equal(result.push.status, "pushed"); + const parent = yield* driver.changeAt(root, "@-"); + assert.include(parent?.localBookmarks ?? [], "main"); + }), + ), + ); + + it.effect("is idempotent when the same action runs twice", () => + withRepo(({ actions, events, fileSystem, path, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "work.txt"), "work\n"); + + const first = yield* actions.runStackedAction( + runInput({ cwd: root, action: "commit_push" }), + reporter(events), + ); + const second = yield* actions.runStackedAction( + runInput({ cwd: root, action: "commit_push" }), + reporter(events), + ); + + assert.equal(first.push.status, "pushed"); + assert.equal(second.commit.status, "skipped_no_changes"); + assert.equal(second.push.status, "skipped_up_to_date"); + }), + ), + ); + + it.effect("commits and pushes the feature bookmark without moving main", () => + withRepo(({ actions, driver, events, fileSystem, path, root }) => + Effect.gen(function* () { + const mainBefore = yield* driver.changeAt(root, 'bookmarks(exact:"main")'); + yield* runJj(root, ["bookmark", "create", "feature/commit-push", "-r", "@"]); + yield* fileSystem.writeFileString(path.join(root, "feature.txt"), "feature\n"); + + const result = yield* actions.runStackedAction( + runInput({ cwd: root, action: "commit_push", commitMessage: "Add feature" }), + reporter(events), + ); + + assert.equal(result.commit.status, "created"); + assert.equal(result.push.status, "pushed"); + assert.equal(result.push.branch, "feature/commit-push"); + const bookmarks = yield* driver.listBookmarks(root); + const local = bookmarks.find( + (bookmark) => bookmark.name === "feature/commit-push" && bookmark.remote === null, + ); + const remote = bookmarks.find( + (bookmark) => bookmark.name === "feature/commit-push" && bookmark.remote === "origin", + ); + assert.isDefined(local?.target); + assert.equal(remote?.target, local?.target); + const mainAfter = yield* driver.changeAt(root, 'bookmarks(exact:"main")'); + assert.equal(mainAfter?.commitId, mainBefore?.commitId); + }), + ), + ); +}); + +describeJj("JjStackedAction partial commits", () => { + it.effect("commits only the selected file when its name reads as a fileset expression", () => + withRepo(({ actions, driver, events, fileSystem, path, root }) => + Effect.gen(function* () { + // `weird~name (1).txt` is a difference expression followed by a syntax error when jj parses + // it as a fileset, so a bare argument commits the wrong files or fails outright. + const selected = "weird~name (1).txt"; + yield* fileSystem.writeFileString(path.join(root, selected), "selected\n"); + yield* fileSystem.writeFileString(path.join(root, "untouched.txt"), "untouched\n"); + + const result = yield* actions.runStackedAction( + runInput({ cwd: root, action: "commit", filePaths: [selected] }), + reporter(events), + ); + + assert.equal(result.commit.status, "created"); + const committed = yield* driver.changeAt(root, "@-"); + assert.deepStrictEqual( + (committed?.fileStats ?? []).map((file) => file.path), + [selected], + ); + const working = yield* driver.currentChange(root); + assert.deepStrictEqual( + working.fileStats.map((file) => file.path), + ["untouched.txt"], + ); + }), + ), + ); + + it.effect("passes every selected path after the argument terminator, escaped", () => + withRepo(({ actions, driver, events, fileSystem, path, root, runs }) => + Effect.gen(function* () { + const optionShaped = "--config=ui.pager=evil"; + const quoted = 'qu"ote.txt'; + yield* fileSystem.writeFileString(path.join(root, optionShaped), "option\n"); + yield* fileSystem.writeFileString(path.join(root, quoted), "quoted\n"); + yield* fileSystem.writeFileString(path.join(root, "untouched.txt"), "untouched\n"); + + yield* actions.runStackedAction( + runInput({ cwd: root, action: "commit", filePaths: [optionShaped, quoted] }), + reporter(events), + ); + + const committed = yield* driver.changeAt(root, "@-"); + assert.deepStrictEqual( + (committed?.fileStats ?? []).map((file) => file.path).toSorted(), + [optionShaped, quoted].toSorted(), + ); + + const commitArgs = runs.find((entry) => entry.args.includes("commit"))?.args ?? []; + assert.notInclude(commitArgs, optionShaped); + assert.include(commitArgs, `file:"${optionShaped}"`); + assert.include(commitArgs, 'file:"qu\\"ote.txt"'); + assert.isBelow(commitArgs.indexOf("--"), commitArgs.indexOf(`file:"${optionShaped}"`)); + }), + ), + ); +}); + +describeJj("JjStackedAction branch phase", () => { + it.effect("bookmarks `@` for a commit action", () => + withRepo(({ actions, driver, events, fileSystem, path, root, runs }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "work.txt"), "work\n"); + + const result = yield* actions.runStackedAction( + runInput({ cwd: root, action: "commit", featureBranch: true }), + reporter(events), + ); + + assert.equal(result.branch.status, "created"); + // The commit phase moves the bookmark to `@-` afterwards either way, so the placement is + // only visible in the argv the branch phase built. + const created = runs.find((run) => run.args.includes("create")); + assert.deepStrictEqual(created?.args.slice(-2), ["-r", "@"]); + const parent = yield* driver.changeAt(root, "@-"); + assert.include(parent?.localBookmarks ?? [], result.branch.name ?? ""); + }), + ), + ); + + it.effect("bookmarks `@-` for a push-only action", () => + withRepo(({ actions, driver, events, fileSystem, path, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "committed.txt"), "committed\n"); + yield* runJj(root, ["commit", "-m", "already committed"]); + yield* fileSystem.writeFileString(path.join(root, "editing.txt"), "editing\n"); + + const result = yield* actions.runStackedAction( + runInput({ cwd: root, action: "push", featureBranch: true }), + reporter(events), + ); + + assert.equal(result.branch.status, "created"); + const parent = yield* driver.changeAt(root, "@-"); + assert.include(parent?.localBookmarks ?? [], result.branch.name ?? ""); + assert.equal(result.push.status, "pushed"); + }), + ), + ); +}); + +describeJj("JjStackedAction push phase", () => { + it.effect("publishes work the agent committed and then bookmarked itself", () => + withRepo(({ actions, createRef, events, fileSystem, path, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "agent.txt"), "agent\n"); + yield* runJj(root, ["commit", "-m", "agent work"]); + yield* createRef(root, "feat/captured"); + + const result = yield* actions.runStackedAction( + runInput({ cwd: root, action: "push" }), + reporter(events), + ); + + assert.equal(result.push.status, "pushed"); + assert.equal(result.push.branch, "feat/captured"); + }), + ), + ); + + it.effect("refuses to publish a conflicted change", () => + withRepo(({ actions, driver, events, fileSystem, path, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "conflict.txt"), "left\n"); + yield* runJj(root, ["commit", "-m", "left"]); + const left = yield* driver.changeAt(root, "@-"); + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + yield* fileSystem.writeFileString(path.join(root, "conflict.txt"), "right\n"); + yield* runJj(root, ["commit", "-m", "right"]); + const right = yield* driver.changeAt(root, "@-"); + yield* runJj(root, ["new", left?.commitId ?? "", right?.commitId ?? ""]); + + const failure = yield* actions + .runStackedAction(runInput({ cwd: root, action: "push" }), reporter(events)) + .pipe(Effect.flip); + + assert.include(failure.detail, "Resolve conflicts in this change before pushing."); + }), + ), + ); +}); diff --git a/apps/server/src/jj/JjStackedAction.ts b/apps/server/src/jj/JjStackedAction.ts new file mode 100644 index 000000000000..f080c96d4f33 --- /dev/null +++ b/apps/server/src/jj/JjStackedAction.ts @@ -0,0 +1,491 @@ +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; + +import { + GitCommandError, + type GitActionProgressEvent, + type GitActionProgressPhase, + type GitRunStackedActionInput, + type GitRunStackedActionResult, +} from "@t3tools/contracts"; +import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; +import { getChangeRequestTerminologyForKind } from "@t3tools/shared/sourceControl"; + +import { + isCommitAction, + resolveCommitAndBranchSuggestion, + resolveTextGenerationSettings, + runChangeRequestStep, + summarizeGitActionResult, + readRecentCommitSubjects, + type ChangeRequestStepServices, + type ChangeRequestVcsReads, + type CommitAndBranchSuggestion, + type GitActionProgressPayload, + type SourceControlTextGenerationSettings, +} from "../git/GitManager.ts"; +import type * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import type * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as JjProcess from "../vcs/JjProcess.ts"; +import { literalFilesetPath } from "../vcs/JjRevset.ts"; +import type { JjSegmentRow, JjVcsDriverShape } from "../vcs/JjVcsDriver.ts"; +import type * as VcsProcess from "../vcs/VcsProcess.ts"; +import { jjFailure, mapJjFailure } from "./JjFailure.ts"; +import type { JjRemoteOps } from "./JjRemotes.ts"; +import { + computeAheadBehindCounts, + resolveWorkspaceRefName, + resolveUpstreamContext, + strandedSegmentRows, +} from "./JjStatus.ts"; + +const COMMIT_TIMEOUT_MS = 10 * 60_000; +const DIFF_SUMMARY_MAX_BYTES = 64 * 1024; +const DIFF_PATCH_MAX_BYTES = 1024 * 1024; + +/** + * The selected paths as jj sees them: after the argument terminator, so a client-supplied path can + * never be read as a jj option, and each one a literal fileset rather than an expression. + */ +function filesetArguments(filePaths: readonly string[] | undefined): ReadonlyArray { + return filePaths === undefined || filePaths.length === 0 + ? [] + : ["--", ...filePaths.map(literalFilesetPath)]; +} + +export interface JjStackedActionDeps { + readonly driver: JjVcsDriverShape; + /** Git against the colocated store: what the shared step's template and history reads run on. */ + readonly executeGit: GitVcsDriver.GitVcsDriver["Service"]["execute"]; + readonly process: VcsProcess.VcsProcess["Service"]; + readonly remotes: JjRemoteOps; + readonly sourceControlProviders: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]; + readonly changeRequestReads: ( + cwd: string, + ) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; +} + +export interface JjStackedActionOps { + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: { + readonly actionId?: string; + readonly progressReporter?: { + readonly publish: (event: GitActionProgressEvent) => Effect.Effect; + }; + }, + ) => Effect.Effect; +} + +export const makeJjStackedAction = (deps: JjStackedActionDeps): JjStackedActionOps => { + const { + changeRequestReads, + driver, + executeGit, + invalidateStatus, + process, + remotes, + sourceControlProviders, + } = deps; + + const run = JjProcess.jjRunner(process); + + /** `@` against `@-`, in the shape the shared message generator reads. */ + const prepareCommitContext = (cwd: string, filePaths?: readonly string[]) => + Effect.gen(function* () { + const operation = "JjStackedAction.prepareCommitContext"; + const paths = filesetArguments(filePaths); + const [summary, patch] = yield* Effect.all([ + run(operation, cwd, ["diff", "-r", "@", "--stat", ...paths], { + timeoutMs: 60_000, + maxOutputBytes: DIFF_SUMMARY_MAX_BYTES, + outputMode: "truncate", + }), + run(operation, cwd, ["diff", "-r", "@", "--git", ...paths], { + timeoutMs: 60_000, + maxOutputBytes: DIFF_PATCH_MAX_BYTES, + outputMode: "truncate", + }), + ]).pipe(mapJjFailure(operation, cwd, "Could not read the working-copy diff.")); + + return summary.stdout.trim().length === 0 && patch.stdout.trim().length === 0 + ? null + : { stagedSummary: summary.stdout, stagedPatch: patch.stdout }; + }); + + const runStackedAction: JjStackedActionOps["runStackedAction"] = Effect.fn( + "JjStackedAction.runStackedAction", + )(function* (input, options) { + const cwd = input.cwd; + const actionId = options?.actionId ?? input.actionId; + const reporter = options?.progressReporter; + const emit = (event: GitActionProgressPayload) => + reporter + ? reporter.publish({ + actionId, + cwd, + action: input.action, + ...event, + } as GitActionProgressEvent) + : Effect.void; + + const currentPhase = yield* Ref.make(null); + + const runAction = Effect.fn("JjStackedAction.runAction")(function* () { + const settings = yield* resolveTextGenerationSettings({ + operation: "JjStackedAction.runStackedAction", + cwd, + ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), + }).pipe( + Effect.mapError((cause) => + jjFailure( + "JjStackedAction.runStackedAction", + cwd, + "Failed to get server settings.", + cause, + ), + ), + ); + + const wantsCommit = isCommitAction(input.action); + const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; + const segment = yield* driver + .currentSegment(cwd) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const initialRefName = yield* resolveWorkspaceRefName(driver, cwd, segment); + const upstream = + initialRefName === null + ? { primaryRemote: null, hasUpstream: false, defaultBookmark: null } + : yield* resolveUpstreamContext(driver, cwd, initialRefName); + // Tolerant by design: a failed count must not block the action, only the create-PR shortcut. + const initialCounts = + initialRefName === null + ? { aheadCount: 0 } + : yield* computeAheadBehindCounts(driver, cwd, { + refName: initialRefName, + primaryRemote: upstream.primaryRemote, + defaultBookmark: upstream.defaultBookmark, + hasUpstream: upstream.hasUpstream, + }).pipe(Effect.orElseSucceed(() => ({ aheadCount: 0 }))); + const wantsPush = + input.action === "push" || + input.action === "commit_push" || + input.action === "commit_push_pr" || + (input.action === "create_pr" && (!upstream.hasUpstream || initialCounts.aheadCount > 0)); + + const phases: GitActionProgressPhase[] = [ + ...(input.featureBranch ? (["branch"] as const) : []), + ...(wantsCommit ? (["commit"] as const) : []), + ...(wantsPush ? (["push"] as const) : []), + ...(wantsPr ? (["pr"] as const) : []), + ]; + yield* emit({ kind: "action_started", phases }); + + // jj has no detached HEAD and no index, so git's three "Cannot ... from detached HEAD" + // refusals and its "commit local changes first" guard have no jj equivalent: a working-copy + // commit carrying no bookmark is the normal state, and the branch phase names it. + const change = yield* driver + .currentChange(cwd) + .pipe(mapJjFailure("JjStackedAction.runStackedAction", cwd, "Could not read `@`.")); + if (change.conflict && (wantsPush || wantsPr)) { + return yield* Effect.fail( + jjFailure( + "JjStackedAction.runStackedAction", + cwd, + "Resolve conflicts in this change before pushing.", + ), + ); + } + + let branchStep: { status: "created" | "skipped_not_requested"; name?: string } = { + status: "skipped_not_requested" as const, + }; + let commitMessageForStep = input.commitMessage; + let preResolvedSuggestion: CommitAndBranchSuggestion | undefined = undefined; + + if (input.featureBranch) { + yield* Ref.set(currentPhase, "branch"); + yield* emit({ + kind: "phase_started", + phase: "branch", + label: "Preparing feature branch...", + }); + const suggestion = yield* resolveCommitAndBranchSuggestion({ + cwd, + branch: initialRefName, + ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + ...(input.filePaths ? { filePaths: input.filePaths } : {}), + includeBranch: true, + settings, + recentSubjects: (historyCwd) => + driver.currentChange(historyCwd).pipe( + Effect.flatMap((change) => + readRecentCommitSubjects(executeGit, historyCwd, change.commitId), + ), + Effect.orElseSucceed(() => []), + ), + prepareCommitContext, + }).pipe( + Effect.mapError((cause) => + jjFailure( + "JjStackedAction.branchPhase", + cwd, + "Could not name a bookmark for this change.", + cause, + ), + ), + ); + if (suggestion === null) { + return yield* Effect.fail( + jjFailure( + "JjStackedAction.branchPhase", + cwd, + "Cannot create a bookmark because there are no changes to commit.", + ), + ); + } + const bookmarks = yield* driver + .listBookmarks(cwd) + .pipe(mapJjFailure("JjStackedAction.branchPhase", cwd, "Could not list bookmarks.")); + const name = resolveAutoFeatureBranchName( + bookmarks.filter((bookmark) => bookmark.remote === null).map((bookmark) => bookmark.name), + suggestion.branch ?? suggestion.subject, + ); + // A commit action is about to describe `@`, so the bookmark belongs on it and the commit + // phase moves it forward; a push-only action publishes committed work, so it belongs on + // `@-`, which is what `git branch` + `git push` do with a dirty tree. + yield* remotes.bookmarkTo({ + cwd, + name, + revset: wantsCommit ? "@" : "@-", + operation: "JjStackedAction.branchPhase", + }); + branchStep = { status: "created" as const, name }; + commitMessageForStep = suggestion.commitMessage; + preResolvedSuggestion = suggestion; + } + + const refName = branchStep.name ?? initialRefName; + + const commit = wantsCommit + ? yield* commitPhase({ + cwd, + refName, + settings, + emit, + currentPhase, + ...(commitMessageForStep !== undefined ? { commitMessage: commitMessageForStep } : {}), + ...(preResolvedSuggestion !== undefined ? { preResolvedSuggestion } : {}), + ...(input.filePaths !== undefined ? { filePaths: input.filePaths } : {}), + }) + : { status: "skipped_not_requested" as const }; + + const push = wantsPush + ? yield* Effect.gen(function* () { + yield* Ref.set(currentPhase, "push"); + yield* emit({ kind: "phase_started", phase: "push", label: "Pushing..." }); + if (refName === null) { + return yield* Effect.fail( + jjFailure( + "JjStackedAction.pushPhase", + cwd, + "This change is not on a bookmark yet. Create one before pushing.", + ), + ); + } + const remoteName = yield* remotes.requirePrimaryRemoteName( + "JjStackedAction.pushPhase", + cwd, + ); + const result = yield* remotes.pushBookmark({ + cwd, + name: refName, + remoteName, + operation: "JjStackedAction.pushPhase", + }); + return { + status: result.status, + branch: result.refName, + upstreamBranch: `${remoteName}/${result.refName}`, + }; + }) + : { status: "skipped_not_requested" as const }; + + const pr = wantsPr + ? yield* Effect.gen(function* () { + yield* Ref.set(currentPhase, "pr"); + const reads = yield* changeRequestReads(cwd); + return yield* runChangeRequestStep({ + settings, + cwd, + fallbackRefName: refName, + emit, + reads, + }).pipe( + Effect.mapError((cause) => + jjFailure("JjStackedAction.prPhase", cwd, cause.message, cause), + ), + ); + }) + : { status: "skipped_not_requested" as const }; + + const result = { + action: input.action, + branch: branchStep, + commit, + push, + pr, + toast: yield* buildToast(cwd, { + action: input.action, + branch: branchStep, + commit, + push, + pr, + }), + }; + yield* emit({ kind: "action_finished", result }); + return result; + }); + + return yield* runAction().pipe( + Effect.ensuring(invalidateStatus(cwd)), + Effect.tapError((error) => + Effect.flatMap(Ref.get(currentPhase), (phase) => + emit({ kind: "action_failed", phase, message: error.message }), + ), + ), + ); + }); + + const commitPhase = Effect.fn("JjStackedAction.commitPhase")(function* (input: { + readonly cwd: string; + readonly refName: string | null; + readonly settings: SourceControlTextGenerationSettings; + readonly emit: (event: GitActionProgressPayload) => Effect.Effect; + readonly currentPhase: Ref.Ref; + readonly commitMessage?: string; + readonly preResolvedSuggestion?: CommitAndBranchSuggestion; + readonly filePaths?: readonly string[]; + }) { + const operation = "JjStackedAction.commitPhase"; + yield* Ref.set(input.currentPhase, "commit"); + + const change = yield* driver + .currentChange(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not read `@`.")); + const segment = yield* driver + .currentSegment(input.cwd) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const hasStrandedWork = strandedSegmentRows(segment).length > 0; + + if (change.empty && !hasStrandedWork) { + return { status: "skipped_no_changes" as const }; + } + + // `@` is empty but the agent's own `jj commit`s sit below it: there is nothing to describe, + // and the work reaches the remote through the bookmark move below. + if (!change.empty) { + const suggestion = + input.preResolvedSuggestion ?? + (yield* Effect.gen(function* () { + if (input.commitMessage === undefined || input.commitMessage.trim().length === 0) { + yield* input.emit({ + kind: "phase_started", + phase: "commit", + label: "Generating commit message...", + }); + } + return yield* resolveCommitAndBranchSuggestion({ + cwd: input.cwd, + branch: input.refName, + ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + ...(input.filePaths ? { filePaths: input.filePaths } : {}), + settings: input.settings, + recentSubjects: (historyCwd) => + driver.currentChange(historyCwd).pipe( + Effect.flatMap((change) => + readRecentCommitSubjects(executeGit, historyCwd, change.commitId), + ), + Effect.orElseSucceed(() => []), + ), + prepareCommitContext, + }).pipe( + Effect.mapError((cause) => + jjFailure(operation, input.cwd, "Could not generate a commit message.", cause), + ), + ); + })); + if (suggestion === null) { + return { status: "skipped_no_changes" as const }; + } + + yield* input.emit({ kind: "phase_started", phase: "commit", label: "Committing..." }); + // No `hook_*` events: jj runs no Git hooks, and a `pre-commit` that inspects + // `git diff --cached` would see an empty index and silently pass, which is worse than + // not running it. The loss is surfaced in the client, not hidden here. + yield* run( + operation, + input.cwd, + ["commit", "-m", suggestion.commitMessage, ...filesetArguments(input.filePaths)], + { timeoutMs: COMMIT_TIMEOUT_MS }, + ).pipe(mapJjFailure(operation, input.cwd, "Could not commit this change.")); + } + + if (input.refName !== null) { + // `jj commit` does not move a bookmark that was on `@`, so the bookmark follows the work + // here; the mover is forward-only, so a re-run is idempotent. + yield* remotes.bookmarkTo({ + cwd: input.cwd, + name: input.refName, + revset: "@-", + operation, + }); + } + + const committed = yield* driver + .changeAt(input.cwd, "@-") + .pipe(Effect.orElseSucceed(() => null)); + return { + status: "created" as const, + ...(committed !== null ? { commitSha: committed.commitId } : {}), + ...(committed !== null && committed.description.trim().length > 0 + ? { subject: committed.description.trim().split("\n")[0] ?? "" } + : {}), + }; + }); + + const buildToast = Effect.fn("JjStackedAction.buildToast")(function* ( + cwd: string, + result: Pick, + ) { + const terms = yield* sourceControlProviders.resolve({ cwd }).pipe( + Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), + Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")), + ); + const summary = summarizeGitActionResult(result, terms); + const isDefaultRef = + result.push.branch !== undefined && + result.push.branch === + (yield* driver.resolveDefaultBookmark(cwd).pipe(Effect.orElseSucceed(() => null))); + + const cta = + result.action === "commit" && result.commit.status === "created" + ? { kind: "run_action" as const, label: "Push", action: { kind: "push" as const } } + : result.pr.url + ? { kind: "open_pr" as const, label: `View ${terms.shortLabel}`, url: result.pr.url } + : (result.action === "push" || result.action === "commit_push") && + result.push.status === "pushed" && + !isDefaultRef + ? { + kind: "run_action" as const, + label: `Create ${terms.shortLabel}`, + action: { kind: "create_pr" as const }, + } + : { kind: "none" as const }; + + return { ...summary, cta }; + }); + + return { runStackedAction }; +}; diff --git a/apps/server/src/jj/JjStatus.test.ts b/apps/server/src/jj/JjStatus.test.ts new file mode 100644 index 000000000000..ead91781fa8b --- /dev/null +++ b/apps/server/src/jj/JjStatus.test.ts @@ -0,0 +1,374 @@ +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 Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as GitManager from "../git/GitManager.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as JjVcsDriver from "../vcs/JjVcsDriver.ts"; +import { + describeJj, + runGit, + runJj, + seedJjRepo, + stubSourceControlProviders, + withJjRepo, +} from "../vcs/testing/JjTestSupport.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { makeJjStatus, refNameFromSegment, type JjStatusOps } from "./JjStatus.ts"; +import { workspaceNameForRef } from "./JjWorkspaceNaming.ts"; + +/** Counts the `jj` spawns the driver itself makes, which is what the status budget is about. */ +const countingDriverLayer = (counter: { count: number }) => + JjVcsDriver.layer.pipe( + Layer.provideMerge( + Layer.effect( + VcsProcess.VcsProcess, + Effect.map(VcsProcess.make, (real) => ({ + run: (input: VcsProcess.VcsProcessInput) => { + if (input.command === "jj") { + counter.count += 1; + } + return real.run(input); + }, + })), + ).pipe(Layer.provide(ProcessRunner.layer)), + ), + Layer.provideMerge(NodeServices.layer), + ); + +const stubGitManager = ( + branchPullRequest: GitManager.GitManager["Service"]["branchPullRequest"] = () => + Effect.succeed(null), +) => + GitManager.GitManager.pipe( + Effect.provide(Layer.mock(GitManager.GitManager)({ branchPullRequest })), + ); + +const makeStatus = ( + driver: JjVcsDriver.JjVcsDriverShape, + process: VcsProcess.VcsProcess["Service"], + branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"], +) => + Effect.gen(function* () { + return yield* makeJjStatus({ + driver, + process, + gitManager: yield* stubGitManager(branchPullRequest), + sourceControlProviders: yield* stubSourceControlProviders, + }); + }); + +interface StatusFixture { + readonly driver: JjVcsDriver.JjVcsDriverShape; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly status: JjStatusOps; + readonly root: string; + readonly remotePath: string; +} + +const withRepo = ( + input: { + readonly withRemote?: boolean; + readonly colocated?: boolean; + readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"]; + }, + use: (fixture: StatusFixture) => Effect.Effect, +) => + withJjRepo({ prefix: "t3-jj-status-", seed: true, ...input }, (repo) => + Effect.gen(function* () { + const { driver, fileSystem, path, process, remotePath, root } = repo; + const status = yield* makeStatus(driver, process, input.branchPullRequest); + + return yield* use({ driver, fileSystem, path, status, root, remotePath }); + }), + ); + +describeJj("JjStatus.localStatus", () => { + it.effect("keeps a managed workspace bookmark when main advances", () => + withRepo({}, ({ path, root, status }) => + Effect.gen(function* () { + const bookmark = "t3code/thread"; + const workspacePath = path.join(path.dirname(root), "thread-workspace"); + yield* runJj(root, ["bookmark", "create", bookmark, "-r", 'bookmarks(exact:"main")']); + yield* runJj(root, [ + "workspace", + "add", + "--name", + workspaceNameForRef(bookmark), + "-m", + `t3:${bookmark}`, + "-r", + `bookmarks(exact:"${bookmark}")`, + workspacePath, + ]); + + const local = yield* status.localStatus({ cwd: workspacePath }); + assert.equal(local.refName, bookmark); + + yield* runJj(workspacePath, ["bookmark", "move", "main", "--to", "@"]); + const afterMainMoves = yield* status.localStatus({ cwd: workspacePath }); + assert.equal(afterMainMoves.refName, bookmark); + }), + ), + ); + + it.effect("reports an empty working copy as clean and names the bookmark", () => + withRepo({}, ({ root, status }) => + Effect.gen(function* () { + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + const local = yield* status.localStatus({ cwd: root }); + + assert.deepStrictEqual(local.vcs, { kind: "jj" }); + assert.equal(local.refName, "main"); + assert.equal(local.isDefaultRef, true); + assert.equal(local.hasWorkingTreeChanges, false); + assert.equal(local.workingTree.files.length, 0); + }), + ), + ); + + it.effect("reports per-file counts for an edited working copy", () => + withRepo({}, ({ fileSystem, path, root, status }) => + Effect.gen(function* () { + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + yield* fileSystem.writeFileString(path.join(root, "a.txt"), "one\ntwo\n"); + yield* fileSystem.writeFileString(path.join(root, "b.txt"), "three\n"); + + const local = yield* status.localStatus({ cwd: root }); + + assert.equal(local.hasWorkingTreeChanges, true); + assert.deepStrictEqual( + local.workingTree.files.map((file) => file.path), + ["a.txt", "b.txt"], + ); + assert.equal(local.workingTree.insertions, 3); + assert.equal(local.workingTree.deletions, 0); + }), + ), + ); + + it.effect("reports changes in a repository that has no bookmarks at all", () => + withJjRepo({ prefix: "t3-jj-status-bare-" }, ({ driver, fileSystem, path, process, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "a.txt"), "a\n"); + yield* fileSystem.writeFileString(path.join(root, "b.txt"), "b\n"); + const status = yield* makeStatus(driver, process); + + const local = yield* status.localStatus({ cwd: root }); + + assert.equal(local.refName, null); + assert.equal(local.workingTree.files.length, 2); + assert.equal(local.hasWorkingTreeChanges, true); + }), + ), + ); + + it.effect("reports clean after the agent committed, because `@` is empty again", () => + withRepo({}, ({ fileSystem, path, root, status }) => + Effect.gen(function* () { + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + yield* fileSystem.writeFileString(path.join(root, "agent.txt"), "agent\n"); + yield* runJj(root, ["commit", "-m", "agent work"]); + + const local = yield* status.localStatus({ cwd: root }); + + assert.equal(local.workingTree.files.length, 0); + assert.equal(local.hasWorkingTreeChanges, false); + }), + ), + ); + + it.effect("keeps `refName` stable across repeated polls", () => + withRepo({}, ({ root, status }) => + Effect.gen(function* () { + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + yield* runJj(root, ["bookmark", "create", "aardvark", "-r", 'bookmarks(exact:"main")']); + + const first = yield* status.localStatus({ cwd: root }); + const second = yield* status.localStatus({ cwd: root }); + + assert.equal(first.refName, "aardvark"); + assert.equal(first.refName, second.refName); + }), + ), + ); + + it.effect("costs two jj subprocesses once the repository caches are warm", () => { + const jjCalls = { count: 0 }; + return Effect.gen(function* () { + const { root } = yield* seedJjRepo({ + prefix: "t3-jj-status-warm-", + seed: true, + }); + + const driver = yield* JjVcsDriver.JjVcsDriver; + const process = yield* VcsProcess.VcsProcess; + const status = yield* makeStatus(driver, process); + + yield* status.localStatus({ cwd: root }); + const warmStart = jjCalls.count; + yield* status.localStatus({ cwd: root }); + + assert.equal(jjCalls.count - warmStart, 2); + }).pipe(Effect.provide(countingDriverLayer(jjCalls))); + }); + + it.effect("names the reason a non-colocated repository cannot be used, without throwing", () => + withRepo({ colocated: false }, ({ root, status }) => + Effect.gen(function* () { + const local = yield* status.localStatus({ cwd: root }); + + assert.equal(local.vcs?.kind, "jj"); + assert.include(local.vcs?.unsupportedReason ?? "", "colocated Jujutsu repositories"); + assert.equal(local.refName, null); + assert.equal(local.hasWorkingTreeChanges, false); + }), + ), + ); + + it.effect("survives a Git ref moved backwards behind jj's back", () => + withRepo({}, ({ fileSystem, path, root, status }) => + Effect.gen(function* () { + const seedCommit = (yield* runGit(root, ["rev-parse", "HEAD"])).trim(); + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + yield* fileSystem.writeFileString(path.join(root, "keep.txt"), "keep\n"); + yield* runJj(root, ["commit", "-m", "work to keep"]); + yield* runJj(root, ["bookmark", "set", "main", "-r", "@-"]); + const keptCommit = (yield* runJj(root, [ + "log", + "-r", + 'bookmarks(exact:"main")', + "--no-graph", + "-T", + "commit_id", + ])).trim(); + + // What an ordinary `git fetch`, `git branch -f` or `gh pr checkout` does to a colocated + // repository: with jj's default, the next snapshotting command abandons the commit and + // deletes the file from disk. + yield* runGit(root, ["update-ref", "refs/heads/main", seedCommit]); + + yield* status.localStatus({ cwd: root }); + + assert.isTrue(yield* fileSystem.exists(path.join(root, "keep.txt"))); + const surviving = yield* runJj(root, [ + "log", + "-r", + keptCommit, + "--no-graph", + "-T", + "description", + ]); + assert.include(surviving, "work to keep"); + }), + ), + ); +}); + +describeJj("JjStatus.remoteStatus", () => { + it.effect("counts the agent's own commits as ahead, not behind", () => + withRepo({ withRemote: true }, ({ fileSystem, path, root, status }) => + Effect.gen(function* () { + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + yield* fileSystem.writeFileString(path.join(root, "one.txt"), "one\n"); + yield* runJj(root, ["commit", "-m", "one"]); + yield* fileSystem.writeFileString(path.join(root, "two.txt"), "two\n"); + yield* runJj(root, ["commit", "-m", "two"]); + + const remote = yield* status.remoteStatus({ cwd: root }); + + assert.equal(remote?.aheadCount, 2); + assert.equal(remote?.behindCount, 0); + assert.equal(remote?.aheadOfDefaultCount, 2); + }), + ), + ); + + it.effect("does not let an empty working copy inflate the ahead count", () => + withRepo({ withRemote: true }, ({ root, status }) => + Effect.gen(function* () { + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + + const remote = yield* status.remoteStatus({ cwd: root }); + + assert.equal(remote?.aheadCount, 0); + assert.equal(remote?.hasUpstream, true); + }), + ), + ); + + it.effect("reports a never-pushed bookmark's commits instead of zeroing Push", () => + withRepo({ withRemote: true }, ({ fileSystem, path, root, status }) => + Effect.gen(function* () { + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + yield* fileSystem.writeFileString(path.join(root, "one.txt"), "one\n"); + yield* runJj(root, ["commit", "-m", "one"]); + yield* fileSystem.writeFileString(path.join(root, "two.txt"), "two\n"); + yield* runJj(root, ["commit", "-m", "two"]); + yield* runJj(root, ["bookmark", "create", "feat/unpushed", "-r", "@-"]); + + const remote = yield* status.remoteStatus({ cwd: root }); + + assert.equal(remote?.hasUpstream, false); + assert.equal(remote?.aheadCount, 2); + assert.equal(remote?.behindCount, 0); + }), + ), + ); + + it.effect("carries the pull request the colocated Git store knows about", () => + withRepo( + { + withRemote: true, + branchPullRequest: () => + Effect.succeed({ + number: 7, + title: "Add the thing", + url: "https://example.com/pull/7", + baseRef: "main", + headRef: "main", + state: "open" as const, + repositoryKey: null, + updatedAt: null, + }), + }, + ({ root, status }) => + Effect.gen(function* () { + yield* runJj(root, ["new", 'bookmarks(exact:"main")']); + + const remote = yield* status.remoteStatus({ cwd: root }); + + // The whole mapping, so the omit-when-undefined fields stay pinned. + assert.deepStrictEqual(remote?.pr, { + number: 7, + title: "Add the thing", + url: "https://example.com/pull/7", + baseRef: "main", + headRef: "main", + state: "open", + updatedAt: null, + }); + }), + ), + ); +}); + +describeJj("refNameFromSegment", () => { + it("takes the lexicographically first name of the last bookmarked row", () => { + assert.equal( + refNameFromSegment([ + { commitId: "a", empty: true, localBookmarks: [] }, + { commitId: "b", empty: false, localBookmarks: ["zeta", "alpha"] }, + ]), + "alpha", + ); + }); + + it("is null when nothing in the segment carries a bookmark", () => { + assert.equal(refNameFromSegment([{ commitId: "a", empty: true, localBookmarks: [] }]), null); + }); +}); diff --git a/apps/server/src/jj/JjStatus.ts b/apps/server/src/jj/JjStatus.ts new file mode 100644 index 000000000000..5c728b0cb393 --- /dev/null +++ b/apps/server/src/jj/JjStatus.ts @@ -0,0 +1,441 @@ +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import { + GitCommandError, + type VcsStatusInput, + type VcsStatusLocalResult, + type VcsStatusRemoteResult, + type VcsStatusResult, +} from "@t3tools/contracts"; + +import type * as GitManager from "../git/GitManager.ts"; +import type * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as JjProcess from "../vcs/JjProcess.ts"; +import { localBookmarkRevset, remoteBookmarkRevset } from "../vcs/JjRevset.ts"; +import type { JjSegmentRow, JjVcsDriverShape } from "../vcs/JjVcsDriver.ts"; +import type * as VcsProcess from "../vcs/VcsProcess.ts"; +import { mapJjFailure } from "./JjFailure.ts"; +import { workspaceNameForRef } from "./JjWorkspaceNaming.ts"; + +/** + * Copied by value from `GitVcsDriverCore.ts`: the background refresh under jj has to behave like + * the one under git, but importing git internals here would tie the two lanes together. + */ +const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); +const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); +const STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN = Duration.seconds(30); +const STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN = Duration.minutes(15); +const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; +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); + +function upstreamRefreshFailureCooldown(consecutiveFailures: number): Duration.Duration { + const exponent = Math.max(0, consecutiveFailures - 1); + const cooldownMs = + Duration.toMillis(STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN) * Math.pow(2, exponent); + return Duration.min(Duration.millis(cooldownMs), STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN); +} + +const EMPTY_LOCAL_STATUS = { + refName: null, + hasPrimaryRemote: false, + isDefaultRef: false, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, +} as const; + +/** + * Fallback bookmark for a workspace: the lexicographically first name on the LAST bookmarked row + * of `heads(::@ & bookmarks())::@`. Managed workspaces use their own bookmark when available. + */ +export function refNameFromSegment(segment: ReadonlyArray): string | null { + for (let index = segment.length - 1; index >= 0; index -= 1) { + const row = segment[index]; + if (row !== undefined && row.localBookmarks.length > 0) { + return [...row.localBookmarks].sort()[0] ?? null; + } + } + return null; +} + +/** A managed workspace's bookmark wins when it shares its base commit with another bookmark. */ +export const resolveWorkspaceRefName = Effect.fn("JjStatus.resolveWorkspaceRefName")(function* ( + driver: JjVcsDriverShape, + cwd: string, + segment: ReadonlyArray, +) { + const fallback = refNameFromSegment(segment); + if (fallback === null) return null; + const paths = yield* driver.repoPaths(cwd).pipe(Effect.option); + if (paths._tag === "None" || paths.value.workspaceRoot === paths.value.mainWorkspaceRoot) { + return fallback; + } + + const workspaces = yield* driver.listWorkspaces(cwd).pipe(Effect.orElseSucceed(() => [])); + const workspaceName = workspaces.find( + (workspace) => workspace.root === paths.value.workspaceRoot, + )?.name; + if (workspaceName === undefined) return fallback; + + for (let index = segment.length - 1; index >= 0; index -= 1) { + const matchingBookmark = segment[index]?.localBookmarks.find( + (bookmark) => workspaceNameForRef(bookmark) === workspaceName, + ); + if (matchingBookmark !== undefined) return matchingBookmark; + } + + // A different bookmark can advance nearer to @, which removes the thread bookmark from the + // driver's nearest-bookmarked segment. Keep the workspace identity while its bookmark remains + // an ancestor of this working copy. + const bookmarks = yield* driver.listBookmarks(cwd).pipe(Effect.orElseSucceed(() => [])); + const workspaceBookmark = bookmarks.find( + (bookmark) => bookmark.remote === null && workspaceNameForRef(bookmark.name) === workspaceName, + ); + if (workspaceBookmark !== undefined) { + const isAncestor = yield* driver + .countRevset(cwd, `@ & ${localBookmarkRevset(workspaceBookmark.name)}::`) + .pipe(Effect.orElseSucceed(() => 0)); + if (isAncestor > 0) return workspaceBookmark.name; + } + return fallback; +}); + +/** + * Work the agent committed but never bookmarked: every row of the segment below `@`. `jj new` + * would hide it from `listRefs` and from the segment query alike, so switching away and removing + * a workspace both have to refuse over it. + */ +export function strandedSegmentRows( + segment: ReadonlyArray, +): ReadonlyArray { + return segment.slice(0, -1).filter((row) => !row.empty && row.localBookmarks.length === 0); +} + +/** + * The primary remote, whether `refName` tracks a bookmark on it, and the repository's default + * bookmark: the three readings every ahead/behind and push decision starts from. + */ +export const resolveUpstreamContext = Effect.fn("JjStatus.resolveUpstreamContext")(function* ( + driver: JjVcsDriverShape, + cwd: string, + refName: string, +): Effect.fn.Return< + { + readonly primaryRemote: string | null; + readonly hasUpstream: boolean; + readonly defaultBookmark: string | null; + }, + never +> { + const [primaryRemote, bookmarks, defaultBookmark] = yield* Effect.all([ + driver.resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)), + driver.listBookmarks(cwd).pipe(Effect.orElseSucceed(() => [])), + driver.resolveDefaultBookmark(cwd).pipe(Effect.orElseSucceed(() => null)), + ]); + const hasUpstream = bookmarks.some( + (bookmark) => + bookmark.name === refName && bookmark.remote === primaryRemote && bookmark.tracked, + ); + return { primaryRemote, hasUpstream, defaultBookmark }; +}); + +export interface JjAheadBehindCounts { + readonly aheadCount: number; + readonly behindCount: number; + readonly aheadOfDefaultCount: number; +} + +/** + * Ahead/behind from revsets rather than from jj's `tracking_*_count` template keywords: those are + * measured from the remote ref's point of view (the inverse of git's) and render an inline error + * into stdout for an untracked bookmark. `~ empty()` drops the empty working-copy commit so "2 + * ahead" means two real changes. Measured to `@`, so an agent's own `jj commit`s count as ahead. + */ +export const computeAheadBehindCounts = Effect.fn("JjStatus.computeAheadBehindCounts")(function* ( + driver: JjVcsDriverShape, + cwd: string, + input: { + readonly refName: string; + readonly primaryRemote: string | null; + readonly defaultBookmark: string | null; + readonly hasUpstream: boolean; + }, +): Effect.fn.Return { + const remoteRevset = + input.primaryRemote === null ? null : remoteBookmarkRevset(input.primaryRemote, input.refName); + const defaultRevset = + input.defaultBookmark === null ? null : localBookmarkRevset(input.defaultBookmark); + + const counts = yield* Effect.all( + { + aheadCount: + remoteRevset === null + ? Effect.succeed(0) + : driver.countRevset(cwd, `(${remoteRevset}..@) ~ empty()`), + behindCount: + remoteRevset === null + ? Effect.succeed(0) + : driver.countRevset(cwd, `(@..${remoteRevset}) ~ empty()`), + aheadOfDefaultCount: + defaultRevset === null + ? Effect.succeed(0) + : driver.countRevset(cwd, `(${defaultRevset}..@) ~ empty()`), + }, + { concurrency: 3 }, + ).pipe( + mapJjFailure( + "JjStatus.computeAheadBehindCounts", + cwd, + "Could not count changes against the remote bookmark.", + ), + ); + + // A never-pushed bookmark still reports what the agent committed, the way git falls back to + // counting against the base branch. Zeroing both would disable Push for every new jj thread. + return input.hasUpstream + ? counts + : { + aheadCount: counts.aheadOfDefaultCount, + behindCount: 0, + aheadOfDefaultCount: counts.aheadOfDefaultCount, + }; +}); + +export interface JjStatusDeps { + readonly driver: JjVcsDriverShape; + readonly process: VcsProcess.VcsProcess["Service"]; + readonly gitManager: GitManager.GitManager["Service"]; + readonly sourceControlProviders: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]; +} + +export interface JjStatusOps { + readonly localStatus: (input: VcsStatusInput) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + options?: { readonly refreshUpstream?: boolean; readonly refreshMissingPullRequest?: boolean }, + ) => Effect.Effect; + readonly status: (input: VcsStatusInput) => Effect.Effect; +} + +export const makeJjStatus = (deps: JjStatusDeps): Effect.Effect => + Effect.gen(function* () { + const { driver, gitManager, process, sourceControlProviders } = deps; + + const upstreamRefreshFailureCounts = new Map(); + const recordUpstreamRefreshFailure = (key: string) => { + const nextCount = (upstreamRefreshFailureCounts.get(key) ?? 0) + 1; + upstreamRefreshFailureCounts.delete(key); + upstreamRefreshFailureCounts.set(key, nextCount); + if (upstreamRefreshFailureCounts.size > STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY) { + const oldestKey = upstreamRefreshFailureCounts.keys().next().value; + if (oldestKey !== undefined) { + upstreamRefreshFailureCounts.delete(oldestKey); + } + } + }; + + const upstreamRefreshCache = yield* Cache.makeWith( + (key) => { + const separatorIndex = key.indexOf("\0"); + const cwd = key.slice(0, separatorIndex); + const remoteName = key.slice(separatorIndex + 1); + return JjProcess.jjCommand( + process, + "JjStatus.refreshUpstream", + cwd, + ["git", "fetch", "--remote", remoteName], + { + env: STATUS_UPSTREAM_REFRESH_ENV, + timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), + }, + ).pipe( + Effect.as(true as const), + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "JjStatus.refreshUpstream", + command: "jj", + cwd, + detail: "Background Jujutsu fetch failed.", + cause, + }), + ), + Effect.tap(() => Effect.sync(() => upstreamRefreshFailureCounts.delete(key))), + Effect.tapError(() => Effect.sync(() => recordUpstreamRefreshFailure(key))), + ); + }, + { + capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, + timeToLive: (exit, key) => + Exit.isSuccess(exit) + ? STATUS_UPSTREAM_REFRESH_INTERVAL + : upstreamRefreshFailureCooldown(upstreamRefreshFailureCounts.get(key) ?? 1), + }, + ); + + const detectProvider = (cwd: string) => + sourceControlProviders.resolveHandle({ cwd }).pipe( + Effect.map((handle) => handle.context?.provider ?? null), + Effect.orElseSucceed(() => null), + ); + + const localStatus: JjStatusOps["localStatus"] = Effect.fn("JjStatus.localStatus")( + function* (input) { + // `ensureUsable` already composes the version probe with the colocation test, so the + // "detected but unusable" reason the client renders comes from exactly one place. + const reason = yield* driver.ensureUsable("JjStatus.localStatus", input.cwd).pipe( + Effect.as(null), + Effect.catch((error) => + Effect.succeed(error._tag === "VcsUnsupportedOperationError" ? error.detail : null), + ), + ); + if (reason !== null) { + return { + isRepo: true, + vcs: { kind: "jj" as const, unsupportedReason: reason }, + ...EMPTY_LOCAL_STATUS, + }; + } + + const readings = yield* Effect.all({ + change: driver.currentChange(input.cwd), + segment: driver.currentSegment(input.cwd), + }).pipe(Effect.option); + + if (readings._tag === "None") { + return { isRepo: true, vcs: { kind: "jj" as const }, ...EMPTY_LOCAL_STATUS }; + } + + const refName = yield* resolveWorkspaceRefName(driver, input.cwd, readings.value.segment); + const files = readings.value.change.fileStats + .map((file) => ({ + path: file.path, + insertions: file.linesAdded, + deletions: file.linesRemoved, + })) + .sort((left, right) => left.path.localeCompare(right.path)); + + const [provider, remoteNames, defaultBookmark] = yield* Effect.all([ + detectProvider(input.cwd), + driver.listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [] as const)), + driver.resolveDefaultBookmark(input.cwd).pipe(Effect.orElseSucceed(() => null)), + ]); + + return { + isRepo: true, + vcs: { kind: "jj" as const }, + ...(provider ? { sourceControlProvider: provider } : {}), + hasPrimaryRemote: remoteNames.length > 0, + isDefaultRef: refName !== null && refName === defaultBookmark, + refName, + hasWorkingTreeChanges: files.length > 0, + workingTree: { + files, + insertions: files.reduce((total, file) => total + file.insertions, 0), + deletions: files.reduce((total, file) => total + file.deletions, 0), + }, + }; + }, + ); + + const remoteStatus: JjStatusOps["remoteStatus"] = Effect.fn("JjStatus.remoteStatus")( + function* (input, options) { + const paths = yield* driver + .ensureUsable("JjStatus.remoteStatus", input.cwd) + .pipe( + mapJjFailure( + "JjStatus.remoteStatus", + input.cwd, + "This Jujutsu repository cannot be read.", + ), + ); + + const segment = yield* driver + .currentSegment(input.cwd) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const refName = yield* resolveWorkspaceRefName(driver, input.cwd, segment); + if (refName === null) { + return { + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }; + } + + const primaryRemote = yield* driver + .resolvePrimaryRemoteName(input.cwd) + .pipe(Effect.orElseSucceed(() => null)); + + if (options?.refreshUpstream === true && primaryRemote !== null) { + yield* Cache.get( + upstreamRefreshCache, + `${paths.mainWorkspaceRoot}\0${primaryRemote}`, + ).pipe(Effect.ignore); + } + + const upstream = yield* resolveUpstreamContext(driver, input.cwd, refName); + const counts = yield* computeAheadBehindCounts(driver, input.cwd, { + refName, + primaryRemote, + defaultBookmark: upstream.defaultBookmark, + hasUpstream: upstream.hasUpstream, + }); + + // The PR half of the product runs against the colocated git store: every local bookmark is + // `refs/heads/` there, so the existing lookup, its cache and its backoff all apply. + const pullRequest = yield* gitManager + .branchPullRequest( + { cwd: paths.mainWorkspaceRoot, branch: refName }, + options?.refreshMissingPullRequest === true ? { refresh: true } : undefined, + ) + .pipe(Effect.orElseSucceed(() => null)); + + return { + ...counts, + hasUpstream: upstream.hasUpstream, + pr: + pullRequest === null + ? null + : { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + baseRef: pullRequest.baseRef, + headRef: pullRequest.headRef, + state: pullRequest.state, + ...(pullRequest.isDraft !== undefined ? { isDraft: pullRequest.isDraft } : {}), + ...(pullRequest.updatedAt !== undefined + ? { updatedAt: pullRequest.updatedAt } + : {}), + }, + }; + }, + ); + + const status: JjStatusOps["status"] = Effect.fn("JjStatus.status")(function* (input) { + const local = yield* localStatus(input); + const remote = yield* remoteStatus(input); + return { + ...local, + ...(remote ?? { + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }; + }); + + return { localStatus, remoteStatus, status }; + }); diff --git a/apps/server/src/jj/JjWorkflow.ts b/apps/server/src/jj/JjWorkflow.ts new file mode 100644 index 000000000000..bd7e08131e59 --- /dev/null +++ b/apps/server/src/jj/JjWorkflow.ts @@ -0,0 +1,507 @@ +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; + +import { GitCommandError } from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import type { ChangeRequestStepServices, ChangeRequestVcsReads } from "../git/GitManager.ts"; +import * as GitManager from "../git/GitManager.ts"; +import type { VcsWorkflowOps, WorkspaceCleanupInspection } from "../git/GitWorkflowService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import type * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as JjProcess from "../vcs/JjProcess.ts"; +import * as JjVcsDriver from "../vcs/JjVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { jjFailure } from "./JjFailure.ts"; +import { makeJjPullRequestThread } from "./JjPullRequestThread.ts"; +import { makeJjRefs } from "./JjRefs.ts"; +import { makeJjRemotes } from "./JjRemotes.ts"; +import { makeJjStackedAction } from "./JjStackedAction.ts"; +import { makeJjStatus, resolveUpstreamContext, resolveWorkspaceRefName } from "./JjStatus.ts"; +import { makeJjWorkspaces } from "./JjWorkspaces.ts"; +import { inspectWorkspaceFiles } from "../vcs/WorkspaceFileSafety.ts"; +import { refNameToRevset } from "../vcs/JjRevset.ts"; +import type { JjWorkspace } from "../vcs/JjVcsDriver.ts"; +import { workspaceNameForRef } from "./JjWorkspaceNaming.ts"; +import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; +import { workspaceSettings } from "../project/WorkspaceSettings.ts"; + +const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 64 * 1024; +const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 64 * 1024; +const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 1024 * 1024; + +export class JjWorkflow extends Context.Service< + JjWorkflow, + VcsWorkflowOps & { + readonly branchPullRequest: GitManager.GitManager["Service"]["branchPullRequest"]; + readonly registeredWorkspaces: ( + cwd: string, + ) => Effect.Effect, GitCommandError>; + readonly inspectWorkspaceForCleanup: ( + cwd: string, + ) => Effect.Effect; + readonly workspaceIntegrationBase: (input: { + readonly cwd: string; + readonly refresh: boolean; + }) => Effect.Effect; + /** + * Publish a fresh repository to a hosting remote: `git remote add` then the bookmark move and + * push, because in a colocated repository Git's own HEAD is usually detached. + */ + readonly publishRepository: (input: { + readonly cwd: string; + readonly remoteName: string; + readonly remoteUrl: string; + }) => Effect.Effect< + { readonly refName: string; readonly status: "pushed" | "remote_added" }, + GitCommandError + >; + } +>()("t3/jj/JjWorkflow") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const config = yield* ServerConfig; + const crypto = yield* Crypto.Crypto; + const driver = yield* JjVcsDriver.JjVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const gitManager = yield* GitManager.GitManager; + const path = yield* Path.Path; + const process = yield* VcsProcess.VcsProcess; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const serverSettingsService = yield* ServerSettings.ServerSettingsService; + const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const textGeneration = yield* TextGeneration.TextGeneration; + + /** The shared change-request step takes its services from context; discharge them once. */ + const withChangeRequestServices = ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ServerSettings.ServerSettingsService, serverSettingsService), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ProviderRegistry.ProviderRegistry, providerRegistry), + Effect.provideService( + SourceControlProviderRegistry.SourceControlProviderRegistry, + sourceControlProviders, + ), + Effect.provideService(TextGeneration.TextGeneration, textGeneration), + ); + + /** Git against the colocated object store. A secondary workspace has no `.git` to discover. */ + const executeGit: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fn( + "JjWorkflow.executeGit", + )(function* (input) { + const paths = yield* driver + .repoPaths(input.cwd) + .pipe( + Effect.mapError((cause) => + jjFailure(input.operation, input.cwd, "This is not a Jujutsu workspace.", cause), + ), + ); + const gitDir = paths.gitDir; + if (gitDir === null) { + return yield* Effect.fail( + jjFailure( + input.operation, + input.cwd, + "This Jujutsu repository has no colocated Git store.", + ), + ); + } + // `colocatedGitCommand` picks the options it passes through; only the nullable timeout, which + // it has no notion of, has to be dropped here. + const { timeoutMs, ...passthrough } = input; + return yield* JjProcess.colocatedGitCommand( + process, + input.operation, + { gitDir, cwd: input.cwd }, + input.args, + typeof timeoutMs === "number" ? { ...passthrough, timeoutMs } : passthrough, + ).pipe( + Effect.mapError((cause) => + jjFailure(input.operation, input.cwd, "Colocated Git command failed.", cause), + ), + ); + }); + + const readConfigValue = (cwd: string, key: string) => + executeGit({ + operation: "JjWorkflow.readConfigValue", + cwd, + args: ["config", "--get", key], + allowNonZeroExit: true, + timeoutMs: 10_000, + }).pipe( + Effect.map((result) => + result.exitCode === 0 && result.stdout.trim().length > 0 ? result.stdout.trim() : null, + ), + Effect.orElseSucceed(() => null), + ); + + const remotes = makeJjRemotes({ driver, process }); + const refs = yield* makeJjRefs({ driver, process }); + const workspaces = makeJjWorkspaces({ + driver, + process, + fileSystem, + path, + worktreesDir: config.worktreesDir, + bookmarkTo: remotes.bookmarkTo, + }); + const status = yield* makeJjStatus({ driver, process, gitManager, sourceControlProviders }); + + const invalidateStatus = (cwd: string) => + Effect.all([refs.invalidateSnapshot(cwd), driver.invalidateRepoCaches(cwd)], { + discard: true, + }); + + /** The colocated-Git half of the shared change-request step. */ + const changeRequestReads = Effect.fn("JjWorkflow.changeRequestReads")(function* ( + cwd: string, + ): Effect.fn.Return { + const paths = yield* driver + .ensureUsable("JjWorkflow.changeRequestReads", cwd) + .pipe( + Effect.mapError((cause) => + jjFailure( + "JjWorkflow.changeRequestReads", + cwd, + "This Jujutsu repository cannot be used.", + cause, + ), + ), + ); + const segment = yield* driver.currentSegment(cwd).pipe(Effect.orElseSucceed(() => [])); + const refName = yield* resolveWorkspaceRefName(driver, cwd, segment); + const upstream = + refName === null + ? { primaryRemote: null, hasUpstream: false } + : yield* resolveUpstreamContext(driver, cwd, refName); + + const rangeHead = (yield* driver + .currentChange(cwd) + .pipe( + Effect.mapError((cause) => + jjFailure( + "JjWorkflow.changeRequestReads", + cwd, + "Could not resolve the working-copy revision.", + cause, + ), + ), + )).commitId; + const resolveRevision = Effect.fn("JjWorkflow.resolveRevision")(function* (revision: string) { + const names = yield* driver.listRemoteNames(cwd); + const change = yield* driver.changeAt(cwd, refNameToRevset(revision, names)); + if (change === null) + return yield* jjFailure( + "JjWorkflow.resolveRevision", + cwd, + `Revision ${revision} is unavailable.`, + ); + return change.commitId; + }); + const readRange = ( + operation: string, + rangeCwd: string, + args: ReadonlyArray, + max: number, + ) => + executeGit({ + operation, + cwd: rangeCwd, + args, + maxOutputBytes: max, + appendTruncationMarker: true, + }).pipe(Effect.map((result) => result.stdout)); + + return { + refName, + hasUpstream: upstream.hasUpstream, + upstreamRef: + upstream.hasUpstream && upstream.primaryRemote !== null + ? `${upstream.primaryRemote}/${refName}` + : null, + readRangeContext: (rangeCwd, baseRef) => + Effect.gen(function* () { + const base = yield* resolveRevision(baseRef).pipe( + Effect.mapError((cause) => + jjFailure( + "JjWorkflow.readRangeContext", + cwd, + "Could not resolve the base revision.", + cause, + ), + ), + ); + return yield* Effect.all( + [ + readRange( + "JjWorkflow.readRangeContext.log", + rangeCwd, + ["log", "--oneline", `${base}..${rangeHead}`], + RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, + ), + readRange( + "JjWorkflow.readRangeContext.diffStat", + rangeCwd, + ["diff", "--stat", `${base}..${rangeHead}`], + RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, + ), + readRange( + "JjWorkflow.readRangeContext.diffPatch", + rangeCwd, + ["diff", "--no-ext-diff", "--patch", "--minimal", `${base}..${rangeHead}`], + RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, + ), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([commitSummary, diffSummary, diffPatch]) => ({ + commitSummary, + diffSummary, + diffPatch, + })), + ); + }), + recentSubjects: (historyCwd) => + GitManager.readRecentCommitSubjects(executeGit, historyCwd, rangeHead), + changeRequestTemplate: (templateCwd, baseRevision) => + resolveRevision(baseRevision).pipe( + Effect.mapError((cause) => + jjFailure( + "JjWorkflow.changeRequestTemplate", + templateCwd, + "Could not resolve the template revision.", + cause, + ), + ), + Effect.flatMap((revision) => detectPrTemplate(templateCwd, revision, executeGit)), + Effect.orElseSucceed(() => Option.none()), + ), + providerCwd: paths.mainWorkspaceRoot, + configuredBaseRef: (configCwd, name) => + readConfigValue(configCwd, `branch.${name}.gh-merge-base`), + headContext: (configCwd, name, upstreamRef) => + GitManager.resolveBranchHeadContext(readConfigValue, configCwd, { + branch: name, + upstreamRef, + }), + resolvePrimaryRemoteName: (remoteCwd) => + remotes.requirePrimaryRemoteName("JjWorkflow.resolvePrimaryRemoteName", remoteCwd), + resolveDefaultRefName: (defaultCwd) => + driver + .resolveDefaultBookmark(defaultCwd) + .pipe(Effect.orElseSucceed(() => null as string | null)), + resolveRemoteTrackingCommit: remotes.resolveRemoteTrackingCommit, + }; + }); + + /** Resolve user-facing names through jj, whose bookmarks can lead the colocated Git refs. */ + const hasCommit: VcsWorkflowOps["hasCommit"] = (input) => + Effect.gen(function* () { + const names = yield* driver.listRemoteNames(input.cwd); + return (yield* driver.changeAt(input.cwd, refNameToRevset(input.refName, names))) !== null; + }).pipe( + Effect.mapError((cause) => + jjFailure("JjWorkflow.hasCommit", input.cwd, "Could not resolve revision.", cause), + ), + ); + + const stackedAction = makeJjStackedAction({ + driver, + executeGit, + process, + remotes, + sourceControlProviders, + changeRequestReads, + invalidateStatus, + }); + + const pullRequestThread = makeJjPullRequestThread({ + driver, + gitManager, + process, + refs, + workspaces, + sourceControlProviders, + projectSetupScriptRunner, + invalidateStatus, + }); + + return JjWorkflow.of({ + branchPullRequest: (input, options) => + Effect.gen(function* () { + const paths = yield* driver + .ensureUsable("JjWorkflow.branchPullRequest", input.cwd) + .pipe( + Effect.mapError((cause) => + jjFailure( + "JjWorkflow.branchPullRequest", + input.cwd, + "This Jujutsu repository cannot be used.", + cause, + ), + ), + ); + yield* JjProcess.exportGitRefs(process, "JjWorkflow.branchPullRequest", input.cwd).pipe( + Effect.mapError((cause) => + jjFailure( + "JjWorkflow.branchPullRequest", + input.cwd, + "Could not synchronize hosting refs.", + cause, + ), + ), + ); + return yield* gitManager.branchPullRequest( + { ...input, cwd: paths.mainWorkspaceRoot }, + options, + ); + }), + registeredWorkspaces: (cwd) => + driver + .listWorkspaces(cwd) + .pipe( + Effect.mapError((cause) => + jjFailure( + "JjWorkflow.registeredWorkspaces", + cwd, + "Could not list registered workspaces.", + cause, + ), + ), + ), + inspectWorkspaceForCleanup: (cwd) => + Effect.gen(function* () { + const paths = yield* driver.ensureUsable("JjWorkflow.inspectWorkspaceForCleanup", cwd); + const change = yield* driver.currentChange(cwd); + const segment = yield* driver.currentSegment(cwd); + const refName = yield* resolveWorkspaceRefName(driver, cwd, segment); + const hasUnpreservedFiles = yield* inspectWorkspaceFiles({ + cwd, + repositoryCwd: cwd, + revision: change.commitId, + fileSystem, + path, + execute: executeGit, + }); + const bookmarks = yield* driver.listBookmarks(cwd); + const workspace = (yield* driver.listWorkspaces(cwd)).find( + (entry) => entry.root === paths.workspaceRoot, + ); + const owned = + workspace !== undefined && + refName !== null && + workspace.name === workspaceNameForRef(refName); + const base = + refName === null + ? null + : bookmarks.find((entry) => entry.remote === null && entry.name === refName); + return { + revision: change.parentCommitIds[0] ?? change.commitId, + refName, + hasWorkingTreeChanges: + !change.empty || + change.conflict || + !owned || + base?.conflict === true || + change.parentCommitIds.length !== 1, + hasUnpreservedFiles: hasUnpreservedFiles.hasUnpreservedFiles, + isSecondary: paths.isSecondaryWorkspace, + }; + }).pipe( + Effect.mapError((cause) => + jjFailure( + "JjWorkflow.inspectWorkspaceForCleanup", + cwd, + "Could not inspect workspace preservation.", + cause, + ), + ), + ), + workspaceIntegrationBase: (input) => + Effect.gen(function* () { + const remote = yield* remotes.requirePrimaryRemoteName( + "JjWorkflow.workspaceIntegrationBase", + input.cwd, + ); + const refName = yield* driver.resolveDefaultBookmark(input.cwd); + if (refName === null) return null; + if (input.refresh) + yield* remotes.fetchRemote({ cwd: input.cwd, remoteName: remote, refName }); + return (yield* remotes.resolveRemoteTrackingCommit({ + cwd: input.cwd, + refName, + fallbackRemoteName: remote, + })).commitSha; + }).pipe( + Effect.mapError((cause) => + jjFailure( + "JjWorkflow.workspaceIntegrationBase", + input.cwd, + "Could not resolve the integration base.", + cause, + ), + ), + ), + hasCommit, + status: status.status, + localStatus: status.localStatus, + remoteStatus: status.remoteStatus, + invalidateLocalStatus: invalidateStatus, + invalidateRemoteStatus: invalidateStatus, + invalidateStatus, + pullCurrentBranch: remotes.pullCurrentBranch, + runStackedAction: (input, options) => + withChangeRequestServices(stackedAction.runStackedAction(input, options)), + resolvePullRequest: pullRequestThread.resolvePullRequest, + preparePullRequestThread: (input) => + workspaceSettings(input).pipe( + Effect.provideService(ServerSettings.ServerSettingsService, serverSettingsService), + Effect.mapError((cause) => + jjFailure( + "JjWorkflow.preparePullRequestThread", + input.cwd, + "Could not resolve the review workspace settings.", + cause, + ), + ), + Effect.flatMap((settings) => + pullRequestThread.preparePullRequestThread(input, { + directory: settings.worktreeDirectory, + projectFolders: settings.worktreeProjectFolders, + submodules: settings.worktreeSubmodules, + }), + ), + ), + listRefs: refs.listRefs, + createWorktree: workspaces.createWorktree, + removeWorktree: workspaces.removeWorktree, + pruneWorktrees: workspaces.pruneWorktrees, + createRef: refs.createRef, + switchRef: refs.switchRef, + renameBranch: refs.renameBranch, + listLocalBranchNames: refs.listLocalBranchNames, + fetchRemote: remotes.fetchRemote, + remoteExists: remotes.remoteExists, + remoteBranchExists: remotes.remoteBranchExists, + resolveRemoteTrackingCommit: remotes.resolveRemoteTrackingCommit, + publishRepository: remotes.publishRepository, + }); +}); + +export const layer = Layer.effect(JjWorkflow, make); diff --git a/apps/server/src/jj/JjWorkspaceNaming.ts b/apps/server/src/jj/JjWorkspaceNaming.ts new file mode 100644 index 000000000000..f640b562c4f3 --- /dev/null +++ b/apps/server/src/jj/JjWorkspaceNaming.ts @@ -0,0 +1,24 @@ +import * as NodeCrypto from "node:crypto"; + +const WORKSPACE_NAME_PREFIX = "t3-"; +const WORKSPACE_DIGEST_LENGTH = 8; +const WORKSPACE_NAME_PATTERN = new RegExp( + `^${WORKSPACE_NAME_PREFIX}.+-[0-9a-f]{${WORKSPACE_DIGEST_LENGTH}}$`, +); + +export function bookmarkDigest(refName: string): string { + return NodeCrypto.createHash("sha256") + .update(refName) + .digest("hex") + .slice(0, WORKSPACE_DIGEST_LENGTH); +} + +/** Includes a digest so bookmarks such as `a/b` and `a-b` name different workspaces. */ +export function workspaceNameForRef(refName: string): string { + return `${WORKSPACE_NAME_PREFIX}${refName.replaceAll("/", "-")}-${bookmarkDigest(refName)}`; +} + +/** True for names generated by {@link workspaceNameForRef}, and false for `default`. */ +export function isT3WorkspaceName(name: string): boolean { + return WORKSPACE_NAME_PATTERN.test(name); +} diff --git a/apps/server/src/jj/JjWorkspaces.test.ts b/apps/server/src/jj/JjWorkspaces.test.ts new file mode 100644 index 000000000000..04c3f24ac89c --- /dev/null +++ b/apps/server/src/jj/JjWorkspaces.test.ts @@ -0,0 +1,512 @@ +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 JjVcsDriver from "../vcs/JjVcsDriver.ts"; +import { describeJj, runJj, withJjRepo } from "../vcs/testing/JjTestSupport.ts"; +import { makeJjRefs, type JjRefsOps } from "./JjRefs.ts"; +import { makeJjRemotes } from "./JjRemotes.ts"; +import { + defaultWorkspacePathForRef, + isT3WorkspaceName, + makeJjWorkspaces, + workspaceNameForRef, + type JjWorkspaceOps, +} from "./JjWorkspaces.ts"; + +interface WorkspacesFixture { + readonly driver: JjVcsDriver.JjVcsDriverShape; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly ops: JjWorkspaceOps; + readonly refs: JjRefsOps; + readonly root: string; + readonly worktreesDir: string; +} + +const withRepo = ( + use: (fixture: WorkspacesFixture) => Effect.Effect, + options?: { readonly withRemote?: boolean }, +) => + withJjRepo({ prefix: "t3-jj-workspaces-", seed: true, ...options }, (repo) => + Effect.gen(function* () { + const { base, driver, fileSystem, path, process, root } = repo; + const worktreesDir = path.join(base, "worktrees"); + yield* fileSystem.makeDirectory(worktreesDir, { recursive: true }); + + const remotes = makeJjRemotes({ driver, process }); + const refs = yield* makeJjRefs({ driver, process }); + const ops = makeJjWorkspaces({ + driver, + process, + fileSystem, + path, + worktreesDir, + bookmarkTo: remotes.bookmarkTo, + }); + + return yield* use({ driver, fileSystem, path, ops, refs, root, worktreesDir }); + }), + ); + +describeJj("workspace naming", () => { + it("keeps bookmarks whose sanitised names collide apart", () => { + assert.notEqual(workspaceNameForRef("a/b"), workspaceNameForRef("a-b")); + assert.isTrue(isT3WorkspaceName(workspaceNameForRef("a/b"))); + }); + + it("never claims the default workspace", () => { + assert.isFalse(isT3WorkspaceName("default")); + }); + + it("lays the workspace out under the worktrees directory", () => { + assert.equal( + defaultWorkspacePathForRef({ + worktreesDir: "/state/worktrees", + mainWorkspaceRoot: "/code/project", + refName: "feat/x", + }), + "/state/worktrees/project/feat-x", + ); + }); +}); + +describeJj("JjWorkspaces.createWorktree", () => { + it.effect( + "honors a configured flat workspace directory and rolls back unsupported submodule setup", + () => + withRepo(({ driver, fileSystem, path, ops, root, worktreesDir }) => + Effect.gen(function* () { + const configured = path.join(worktreesDir, "custom"); + const flat = yield* ops.createWorktree( + { cwd: root, refName: "main", newRefName: "topic/flat", path: null }, + { directory: configured, projectFolders: false }, + ); + assert.equal(flat.worktree.path, path.join(configured, "project-topic-flat")); + yield* fileSystem.writeFileString( + path.join(root, ".gitmodules"), + '[submodule "example"]\npath = example\nurl = https://example.com/example.git\n', + ); + yield* runJj(root, ["commit", "-m", "Add submodule configuration"]); + const base = (yield* runJj(root, [ + "log", + "-r", + "@-", + "--no-graph", + "-T", + "commit_id", + ])).trim(); + const destination = path.join(configured, "unsupported"); + for (const existingDirectory of [false, true]) { + if (existingDirectory) yield* fileSystem.makeDirectory(destination); + const error = yield* ops + .createWorktree( + { cwd: root, refName: base, newRefName: "topic/submodules", path: destination }, + { submodules: "recursive" }, + ) + .pipe(Effect.flip); + assert.include(error.detail, "does not support initializing"); + assert.equal(yield* fileSystem.exists(destination), existingDirectory); + if (existingDirectory) + assert.deepStrictEqual(yield* fileSystem.readDirectory(destination), []); + assert.isFalse( + (yield* driver.listWorkspaces(root)).some( + (workspace) => workspace.name === workspaceNameForRef("topic/submodules"), + ), + ); + assert.isFalse( + (yield* driver.listBookmarks(root)).some( + (bookmark) => bookmark.name === "topic/submodules", + ), + ); + } + const retried = yield* ops.createWorktree( + { cwd: root, refName: base, newRefName: "topic/submodules", path: destination }, + { submodules: "none" }, + ); + assert.equal(retried.worktree.path, destination); + }), + ), + ); + it.effect("mints the bookmark and lands under the worktrees directory", () => + withRepo(({ ops, root, worktreesDir }) => + Effect.gen(function* () { + const created = yield* ops.createWorktree({ + cwd: root, + refName: "main", + newRefName: "feat/one", + path: null, + }); + + assert.equal(created.worktree.refName, "feat/one"); + assert.isTrue(created.worktree.path.startsWith(worktreesDir)); + const bookmarks = yield* runJj(root, ["bookmark", "list"]); + assert.include(bookmarks, "feat/one"); + }), + ), + ); + + it.effect("starts named and unnamed workspaces from a resolved commit ID", () => + withRepo(({ ops, root }) => + Effect.gen(function* () { + const commitId = (yield* runJj(root, [ + "log", + "-r", + 'bookmarks(exact:"main")', + "--no-graph", + "-T", + "commit_id", + ])).trim(); + + const named = yield* ops.createWorktree({ + cwd: root, + refName: commitId, + newRefName: "topic/from-origin", + path: null, + }); + assert.equal(named.worktree.refName, "topic/from-origin"); + assert.equal( + (yield* runJj(named.worktree.path, [ + "log", + "-r", + "@-", + "--no-graph", + "-T", + "commit_id", + ])).trim(), + commitId, + ); + + const unnamed = yield* ops.createWorktree({ cwd: root, refName: commitId, path: null }); + assert.equal(unnamed.worktree.refName, commitId); + assert.equal( + (yield* runJj(unnamed.worktree.path, [ + "log", + "-r", + "@-", + "--no-graph", + "-T", + "commit_id", + ])).trim(), + commitId, + ); + }), + ), + ); + + it.effect("is idempotent for the same bookmark", () => + withRepo(({ ops, root }) => + Effect.gen(function* () { + const first = yield* ops.createWorktree({ + cwd: root, + refName: "main", + newRefName: "feat/two", + path: null, + }); + const second = yield* ops.createWorktree({ cwd: root, refName: "feat/two", path: null }); + + assert.equal(second.worktree.path, first.worktree.path); + }), + ), + ); + + it.effect("gives two colliding bookmark names two workspaces", () => + withRepo(({ ops, root }) => + Effect.gen(function* () { + const slashed = yield* ops.createWorktree({ + cwd: root, + refName: "main", + newRefName: "a/b", + path: null, + }); + const dashed = yield* ops.createWorktree({ + cwd: root, + refName: "main", + newRefName: "a-b", + path: null, + }); + + assert.notEqual(slashed.worktree.path, dashed.worktree.path); + }), + ), + ); + + it.effect("records the pull-request base in the colocated Git config", () => + withRepo(({ ops, root }) => + Effect.gen(function* () { + yield* ops.createWorktree({ + cwd: root, + refName: "main", + newRefName: "feat/base", + baseRefName: "main", + path: null, + }); + + const gitConfig = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => fileSystem.readFileString(`${root}/.git/config`)), + ); + assert.include(gitConfig, "gh-merge-base"); + assert.include(gitConfig, "feat/base"); + }), + ), + ); + + it.effect("records the thread bookmark's upstream when the base came from a remote", () => + withRepo( + ({ ops, root }) => + Effect.gen(function* () { + yield* ops.createWorktree({ + cwd: root, + refName: "origin/main", + newRefName: "feat/forked", + baseRefName: "origin/main", + path: null, + }); + + // Without these the hosting side reads no upstream for the thread's bookmark and never + // probes owner-qualified pull-request heads, which is how fork PRs go missing. + const gitConfig = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => fileSystem.readFileString(`${root}/.git/config`)), + ); + assert.include(gitConfig, '[branch "feat/forked"]'); + assert.include(gitConfig, "remote = origin"); + assert.include(gitConfig, "merge = refs/heads/feat/forked"); + }), + { withRemote: true }, + ), + ); + + it.effect("re-creates a registered workspace whose directory is gone", () => + withRepo(({ fileSystem, ops, root }) => + Effect.gen(function* () { + const created = yield* ops.createWorktree({ + cwd: root, + refName: "main", + newRefName: "feat/healed", + path: null, + }); + yield* fileSystem.remove(created.worktree.path, { recursive: true }); + + const healed = yield* ops.createWorktree({ + cwd: root, + refName: "feat/healed", + path: null, + }); + + assert.equal(healed.worktree.path, created.worktree.path); + assert.isTrue(yield* fileSystem.exists(`${healed.worktree.path}/.jj`)); + }), + ), + ); + + it.effect("leaves no registered workspace behind when `jj workspace add` fails", () => + withRepo(({ driver, fileSystem, ops, root, path }) => + Effect.gen(function* () { + const destination = path.join(root, "..", "blocked"); + // A file where jj wants to create the leaf directory: jj registers the workspace and then + // fails, which is exactly the half-created state the cleanup exists for. + yield* fileSystem.writeFileString(destination, "not a directory\n"); + + const failure = yield* ops + .createWorktree({ + cwd: root, + refName: "main", + newRefName: "feat/blocked", + path: destination, + }) + .pipe(Effect.flip); + assert.equal(failure._tag, "GitCommandError"); + + const workspaces = yield* driver.listWorkspaces(root); + assert.isUndefined( + workspaces.find((workspace) => workspace.name === workspaceNameForRef("feat/blocked")), + ); + + yield* fileSystem.remove(destination); + const retried = yield* ops.createWorktree({ + cwd: root, + refName: "main", + newRefName: "feat/blocked", + path: destination, + }); + assert.equal(retried.worktree.refName, "feat/blocked"); + }), + ), + ); +}); + +describeJj("JjWorkspaces.removeWorktree", () => { + it.effect("refuses a path that is not a registered workspace and leaves it on disk", () => + withRepo(({ fileSystem, ops, path, root }) => + Effect.gen(function* () { + const stranger = path.join(root, "..", "not-a-workspace"); + yield* fileSystem.makeDirectory(stranger, { recursive: true }); + yield* fileSystem.writeFileString(path.join(stranger, "keep.txt"), "keep\n"); + + const failure = yield* ops + .removeWorktree({ cwd: root, path: stranger, force: true }) + .pipe(Effect.flip); + + assert.include(failure.detail, "No Jujutsu workspace is registered at"); + assert.isTrue(yield* fileSystem.exists(path.join(stranger, "keep.txt"))); + }), + ), + ); + + it.effect("refuses the main workspace and leaves the project untouched", () => + withRepo(({ fileSystem, ops, root }) => + Effect.gen(function* () { + const failure = yield* ops + .removeWorktree({ cwd: root, path: root, force: true }) + .pipe(Effect.flip); + + assert.include(failure.detail, "is the main Jujutsu workspace and cannot be removed"); + assert.isTrue(yield* fileSystem.exists(`${root}/.jj`)); + assert.isTrue(yield* fileSystem.exists(`${root}/.git`)); + }), + ), + ); + + it.effect("refuses work the agent wrote with no jj command since", () => + withRepo(({ fileSystem, ops, path, root }) => + Effect.gen(function* () { + const created = yield* ops.createWorktree({ + cwd: root, + refName: "main", + newRefName: "feat/dirty", + path: null, + }); + const agentFile = path.join(created.worktree.path, "agent.txt"); + yield* fileSystem.writeFileString(agentFile, "unsnapshotted\n"); + + const failure = yield* ops + .removeWorktree({ cwd: root, path: created.worktree.path }) + .pipe(Effect.flip); + + assert.include(failure.detail, "uncommitted or unbookmarked changes"); + assert.isTrue(yield* fileSystem.exists(agentFile)); + }), + ), + ); + + it.effect("preserves committed unbookmarked work before clean removal", () => + withRepo(({ fileSystem, ops, refs, root }) => + Effect.gen(function* () { + const created = yield* ops.createWorktree({ + cwd: root, + refName: "main", + newRefName: "topic/committed", + path: null, + }); + yield* fileSystem.writeFileString(`${created.worktree.path}/agent.txt`, "work\n"); + yield* runJj(created.worktree.path, ["commit", "-m", "agent work"]); + + yield* ops.removeWorktree({ cwd: root, path: created.worktree.path }); + + assert.isFalse(yield* fileSystem.exists(created.worktree.path)); + const listed = yield* refs.listRefs({ cwd: root, refresh: true }); + const bookmark = listed.refs.find((ref) => ref.name === "topic/committed"); + assert.isDefined(bookmark); + const described = yield* runJj(root, [ + "log", + "-r", + 'bookmarks(exact:"topic/committed")', + "--no-graph", + "-T", + "description", + ]); + assert.include(described, "agent work"); + }), + ), + ); + + it.effect("never advances main when the workspace bookmark was removed", () => + withRepo(({ fileSystem, ops, root }) => + Effect.gen(function* () { + const created = yield* ops.createWorktree({ + cwd: root, + refName: "main", + newRefName: "topic/removed", + path: null, + }); + const originalMain = yield* runJj(root, [ + "log", + "-r", + 'bookmarks(exact:"main")', + "--no-graph", + "-T", + "commit_id", + ]); + yield* runJj(root, ["bookmark", "delete", "topic/removed"]); + yield* fileSystem.writeFileString(`${created.worktree.path}/agent.txt`, "work\n"); + yield* runJj(created.worktree.path, ["commit", "-m", "agent work"]); + + const failure = yield* ops + .removeWorktree({ cwd: root, path: created.worktree.path, force: true }) + .pipe(Effect.flip); + assert.include(failure.detail, "no recoverable bookmark"); + assert.isTrue(yield* fileSystem.exists(created.worktree.path)); + + const currentMain = yield* runJj(root, [ + "log", + "-r", + 'bookmarks(exact:"main")', + "--no-graph", + "-T", + "commit_id", + ]); + assert.equal(currentMain, originalMain); + }), + ), + ); + + it.effect("forgets and deletes a clean workspace with force", () => + withRepo(({ driver, fileSystem, ops, root }) => + Effect.gen(function* () { + const created = yield* ops.createWorktree({ + cwd: root, + refName: "main", + newRefName: "feat/clean", + path: null, + }); + + yield* ops.removeWorktree({ cwd: root, path: created.worktree.path, force: true }); + + assert.isFalse(yield* fileSystem.exists(created.worktree.path)); + const workspaces = yield* driver.listWorkspaces(root); + assert.isUndefined( + workspaces.find((workspace) => workspace.name === workspaceNameForRef("feat/clean")), + ); + }), + ), + ); +}); + +describeJj("JjWorkspaces.pruneWorktrees", () => { + it.effect("reclaims a T3 workspace whose directory is gone and keeps the user's own", () => + withRepo(({ driver, fileSystem, ops, path, root }) => + Effect.gen(function* () { + const created = yield* ops.createWorktree({ + cwd: root, + refName: "main", + newRefName: "feat/pruned", + path: null, + }); + const userWorkspace = path.join(root, "..", "mine"); + yield* runJj(root, ["workspace", "add", "--name", "mine", userWorkspace]); + + yield* fileSystem.remove(created.worktree.path, { recursive: true }); + yield* fileSystem.remove(userWorkspace, { recursive: true }); + + yield* ops.pruneWorktrees({ cwd: root }); + + const workspaces = yield* driver.listWorkspaces(root); + assert.isUndefined( + workspaces.find((workspace) => workspace.name === workspaceNameForRef("feat/pruned")), + ); + assert.isDefined(workspaces.find((workspace) => workspace.name === "mine")); + }), + ), + ); +}); diff --git a/apps/server/src/jj/JjWorkspaces.ts b/apps/server/src/jj/JjWorkspaces.ts new file mode 100644 index 000000000000..bcee316e2add --- /dev/null +++ b/apps/server/src/jj/JjWorkspaces.ts @@ -0,0 +1,565 @@ +// @effect-diagnostics nodeBuiltinImport:off - the exported path helper is pure, so it cannot take +// the Path service; the operations below use the injected one. +import * as NodePath from "node:path"; + +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import type * as Path from "effect/Path"; + +import type { VcsWorkflowOps } from "../git/GitWorkflowService.ts"; +import * as JjProcess from "../vcs/JjProcess.ts"; +import { localBookmarkRevset, refNameToRevset, splitRemoteRefName } from "../vcs/JjRevset.ts"; +import type { JjSegmentRow, JjVcsDriverShape, JjWorkspace } from "../vcs/JjVcsDriver.ts"; +import type * as VcsProcess from "../vcs/VcsProcess.ts"; +import { jjFailure, mapJjFailure } from "./JjFailure.ts"; +import { + assertBookmarkUsable, + trackRemoteBookmark, + writeBookmarkUpstreamConfig, + type JjRemoteOps, +} from "./JjRemotes.ts"; +import { resolveWorkspaceRefName, strandedSegmentRows } from "./JjStatus.ts"; +import { bookmarkDigest, isT3WorkspaceName, workspaceNameForRef } from "./JjWorkspaceNaming.ts"; +import { resolveWorktreesRoot } from "../pathExpansion.ts"; +import { inspectWorkspaceFiles } from "../vcs/WorkspaceFileSafety.ts"; +import { T3_PROJECT_FILE_NAME } from "@t3tools/contracts"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; +import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; + +export { isT3WorkspaceName, workspaceNameForRef } from "./JjWorkspaceNaming.ts"; +const WORKSPACE_ADD_TIMEOUT_MS = 300_000; + +/** + * `//`, the layout + * `AgentSessionScanner.isT3ManagedWorktree` and `ReviewService.assertWorkspaceBoundCwd` classify + * by string prefix. A workspace outside it stops being recognised as a thread environment. + */ +export function defaultWorkspacePathForRef(input: { + readonly worktreesDir: string; + readonly mainWorkspaceRoot: string; + readonly refName: string; + readonly projectFolders?: boolean; +}): string { + const projectName = NodePath.basename(input.mainWorkspaceRoot); + const directoryName = input.refName.replaceAll("/", "-"); + return NodePath.join( + input.worktreesDir, + ...(input.projectFolders === false ? [] : [projectName]), + input.projectFolders === false ? `${projectName}-${directoryName}` : directoryName, + ); +} + +export interface JjWorkspaceOpsDeps { + readonly driver: JjVcsDriverShape; + readonly process: VcsProcess.VcsProcess["Service"]; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly worktreesDir: string; + readonly bookmarkTo: JjRemoteOps["bookmarkTo"]; +} + +export type JjWorkspaceOps = Pick< + VcsWorkflowOps, + "createWorktree" | "removeWorktree" | "pruneWorktrees" +>; + +export const makeJjWorkspaces = (deps: JjWorkspaceOpsDeps): JjWorkspaceOps => { + const { bookmarkTo, driver, fileSystem, path, process, worktreesDir } = deps; + + const run = JjProcess.jjRunner(process); + + const canonicalize = (value: string) => + fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); + + const isJjWorkspaceDirectory = (candidate: string) => + fileSystem.exists(path.join(candidate, ".jj")).pipe(Effect.orElseSucceed(() => false)); + + const forgetWorkspace = (operation: string, cwd: string, name: string) => + run(operation, cwd, ["workspace", "forget", name], { + timeoutMs: 30_000, + }); + + const removeIfOnlyJjMetadata = (directory: string) => + Effect.gen(function* () { + const entries = yield* fileSystem + .readDirectory(directory) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + if (entries.length === 0 || (entries.length === 1 && entries[0] === ".jj")) { + yield* fileSystem.remove(directory, { recursive: true }).pipe(Effect.ignore); + } + }); + + const preserveRegisteredWorkspace = Effect.fn("JjWorkspaces.preserveRegisteredWorkspace")( + function* (operation: string, cwd: string, name: string, bookmark: string) { + const change = yield* driver + .changeAt(cwd, `${name}@`) + .pipe( + mapJjFailure(operation, cwd, "Could not read the missing workspace's last snapshot."), + ); + if (change === null) + return yield* jjFailure( + operation, + cwd, + "The missing workspace has no recoverable revision.", + ); + const revision = + change.empty && change.parentCommitIds.length === 1 + ? change.parentCommitIds[0]! + : change.commitId; + yield* bookmarkTo({ operation, cwd, name: bookmark, revset: revision }); + }, + ); + + const createWorktree: JjWorkspaceOps["createWorktree"] = Effect.fn("JjWorkspaces.createWorktree")( + function* (input, options) { + const operation = "JjWorkspaces.createWorktree"; + const paths = yield* driver + .ensureUsable(operation, input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "This Jujutsu repository cannot be used.")); + + const remoteNames = yield* driver + .listRemoteNames(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not read this repository's remotes.")); + const remoteBase = splitRemoteRefName(input.refName, remoteNames); + if (remoteBase !== null) { + // Tracked whenever a remote base is named, not only alongside `newRefName`: otherwise + // `jj workspace add -r bookmarks(exact:"origin/featX")` registers a workspace and then + // fails on an empty revision set, leaving a half-created directory behind. + yield* trackRemoteBookmark({ + process, + operation, + cwd: input.cwd, + gitDir: paths.gitDir, + remoteName: remoteBase.remote, + name: remoteBase.name, + }); + } + const baseRef = remoteBase?.name ?? input.refName; + const baseRevset = + remoteBase === null ? refNameToRevset(baseRef, []) : localBookmarkRevset(baseRef); + const targetRef = input.newRefName ?? baseRef; + const targetRevset = + input.newRefName === undefined ? baseRevset : localBookmarkRevset(targetRef); + const workspaceName = workspaceNameForRef(targetRef); + + const workspaces = yield* driver + .listWorkspaces(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not list Jujutsu workspaces.")); + const existing = workspaces.find((workspace) => workspace.name === workspaceName); + + const existingRoot = existing?.root ?? null; + if (existing !== undefined) { + const healthy = + existingRoot !== null && + (yield* fileSystem.exists(existingRoot).pipe(Effect.orElseSucceed(() => false))) && + (yield* isJjWorkspaceDirectory(existingRoot)); + if (healthy && existingRoot !== null) { + return { worktree: { path: existingRoot, refName: targetRef } }; + } + // A registered workspace whose directory is gone, or that holds no `.jj`, is the + // half-created state a failed `jj workspace add` leaves behind. + yield* preserveRegisteredWorkspace(operation, input.cwd, workspaceName, targetRef); + yield* forgetWorkspace(operation, paths.mainWorkspaceRoot, workspaceName).pipe( + mapJjFailure(operation, input.cwd, "Could not deregister the missing workspace."), + ); + if (existingRoot !== null) { + yield* removeIfOnlyJjMetadata(existingRoot); + } + } + + const workspaceDirectory = resolveWorktreesRoot(options?.directory ?? "", worktreesDir, path); + const requestedPath = + input.path ?? + defaultWorkspacePathForRef({ + worktreesDir: workspaceDirectory, + mainWorkspaceRoot: paths.mainWorkspaceRoot, + refName: targetRef, + ...(options?.projectFolders !== undefined + ? { projectFolders: options.projectFolders } + : {}), + }); + const resolvedRequestedPath = yield* canonicalize(requestedPath); + const takenByAnotherWorkspace = workspaces.some( + (workspace) => + workspace.name !== workspaceName && + workspace.root !== null && + workspace.root === resolvedRequestedPath, + ); + const worktreePath = takenByAnotherWorkspace + ? `${requestedPath}-${bookmarkDigest(targetRef)}` + : requestedPath; + + const destinationExisted = yield* fileSystem + .exists(worktreePath) + .pipe(Effect.orElseSucceed(() => false)); + if (destinationExisted) { + const entries = yield* fileSystem + .readDirectory(worktreePath) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + if (entries.length > 0) { + return yield* Effect.fail( + jjFailure( + operation, + input.cwd, + `${worktreePath} already exists and is not a Jujutsu workspace.`, + ), + ); + } + } + + yield* assertBookmarkUsable(driver, operation, input.cwd, targetRef); + const localBookmarkExists = + input.newRefName !== undefined && + (yield* driver + .listBookmarks(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not list Jujutsu bookmarks."))).some( + (bookmark) => bookmark.remote === null && bookmark.name === input.newRefName, + ); + if (input.newRefName !== undefined && !localBookmarkExists) { + yield* run( + operation, + input.cwd, + ["bookmark", "create", input.newRefName, "-r", baseRevset], + { timeoutMs: 20_000 }, + ).pipe( + mapJjFailure(operation, input.cwd, `Could not create bookmark ${input.newRefName}.`), + ); + } + + const rollbackBookmark = + input.newRefName !== undefined && !localBookmarkExists + ? run(operation, input.cwd, ["bookmark", "delete", input.newRefName], { + timeoutMs: 20_000, + }).pipe(Effect.ignore) + : Effect.void; + + let workspaceAdded = false; + // jj creates only the leaf directory; a missing parent fails with "Cannot access ...". + yield* Effect.gen(function* () { + yield* fileSystem + .makeDirectory(path.dirname(worktreePath), { recursive: true }) + .pipe( + mapJjFailure(operation, input.cwd, `Could not create ${path.dirname(worktreePath)}.`), + ); + + // `-r X` means "@ is a fresh empty child of X". The bookmark stays on X and the thread's + // edits land in `@`, which is git's `worktree add -b new base` translated. + yield* run( + operation, + input.cwd, + [ + "workspace", + "add", + "--name", + workspaceName, + "-m", + `t3:${targetRef}`, + "-r", + targetRevset, + worktreePath, + ], + { timeoutMs: WORKSPACE_ADD_TIMEOUT_MS }, + ).pipe( + mapJjFailure(operation, input.cwd, `Could not create a workspace at ${worktreePath}.`), + ); + workspaceAdded = true; + + yield* options?.progress?.onWorktreeClaimed?.(worktreePath) ?? Effect.void; + if ( + yield* fileSystem + .exists(path.join(worktreePath, ".gitmodules")) + .pipe(mapJjFailure(operation, input.cwd, "Could not inspect submodule configuration.")) + ) { + const projectFile = + options?.submodules != null + ? null + : yield* fileSystem + .readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)) + .pipe( + Effect.map(parseT3ProjectFile), + Effect.orElseSucceed(() => null), + ); + const submodules = resolveProjectFileBackedSetting( + "worktreeSubmodules", + options?.submodules ?? null, + projectFile, + ); + if (submodules.value !== "none") { + return yield* jjFailure( + operation, + input.cwd, + "Jujutsu workspace creation does not support initializing Git submodules.", + ); + } + yield* ( + options?.progress?.onSubmodulesDisabled?.({ + source: submodules.source === "t3.json" ? "t3.json" : "settings", + }) ?? Effect.void + ); + } + yield* JjProcess.exportGitRefs(process, operation, input.cwd).pipe( + mapJjFailure(operation, input.cwd, "Could not synchronize the workspace bookmark."), + ); + }).pipe( + Effect.onError(() => + Effect.all( + [ + rollbackBookmark.pipe( + Effect.andThen( + JjProcess.exportGitRefs(process, operation, input.cwd).pipe(Effect.ignore), + ), + ), + forgetWorkspace(operation, paths.mainWorkspaceRoot, workspaceName).pipe( + Effect.ignore, + ), + Effect.gen(function* () { + if (workspaceAdded) yield* fileSystem.remove(worktreePath, { recursive: true }); + else yield* removeIfOnlyJjMetadata(worktreePath); + if (destinationExisted) + yield* fileSystem.makeDirectory(worktreePath, { recursive: true }); + }).pipe(Effect.ignore), + ], + { discard: true }, + ), + ), + ); + + const baseRefNameForConfig = + input.baseRefName === undefined + ? undefined + : (splitRemoteRefName(input.baseRefName, remoteNames)?.name ?? input.baseRefName); + yield* writeWorktreeGitConfig({ + process, + gitDir: paths.gitDir, + cwd: input.cwd, + targetRef, + ...(remoteBase !== null ? { upstreamRemoteName: remoteBase.remote } : {}), + ...(input.newRefName !== undefined ? { newRefName: input.newRefName } : {}), + ...(baseRefNameForConfig !== undefined ? { baseRefName: baseRefNameForConfig } : {}), + }); + + return { worktree: { path: worktreePath, refName: targetRef } }; + }, + ); + + const removeWorktree: JjWorkspaceOps["removeWorktree"] = Effect.fn("JjWorkspaces.removeWorktree")( + function* (input) { + const operation = "JjWorkspaces.removeWorktree"; + const paths = yield* driver + .ensureUsable(operation, input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "This Jujutsu repository cannot be used.")); + const workspaces = yield* driver + .listWorkspaces(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not list Jujutsu workspaces.")); + + const target = yield* canonicalize(input.path); + // A workspace jj lists with no root has lost its directory, and an empty root realpaths to + // the server's own cwd, so a null root must never match anything. + const matched = workspaces.find( + (workspace): workspace is JjWorkspace & { readonly root: string } => + workspace.root !== null && workspace.root === target, + ); + + if (matched === undefined) { + return yield* Effect.fail( + jjFailure(operation, input.cwd, `No Jujutsu workspace is registered at ${input.path}.`), + ); + } + if (matched.name === "default" || matched.root === paths.mainWorkspaceRoot) { + // `jj workspace forget default` is permitted by jj and leaves the main root without a + // working copy; `git worktree remove` refuses the main worktree, and so does this. + return yield* Effect.fail( + jjFailure( + operation, + input.cwd, + `${input.path} is the main Jujutsu workspace and cannot be removed.`, + ), + ); + } + + const directoryExists = yield* fileSystem + .exists(matched.root) + .pipe(Effect.orElseSucceed(() => false)); + if (!directoryExists) { + yield* forgetWorkspace(operation, paths.mainWorkspaceRoot, matched.name).pipe( + Effect.ignore, + ); + return; + } + + // `currentChange`, not `changeAt`: `changeAt` always passes `--ignore-working-copy` and so + // reports the last snapshot, under which an agent that just wrote files reads as empty. + const change = yield* driver.currentChange(input.path).pipe(Effect.option); + const segment = yield* driver + .currentSegment(input.path) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const stranded = strandedSegmentRows(segment).length > 0; + // An unreadable workspace is not a licence to delete it. + const dirty = Option.isNone(change) || !change.value.empty || stranded; + + if (input.force !== true && Option.isSome(change)) { + const unpreserved = yield* inspectWorkspaceFiles({ + cwd: input.path, + repositoryCwd: paths.mainWorkspaceRoot, + revision: change.value.commitId, + fileSystem, + path, + execute: (request) => + JjProcess.colocatedGitCommand( + process, + request.operation, + { gitDir: paths.gitDir, cwd: request.cwd }, + request.args, + { + ...(request.env ? { env: request.env } : {}), + ...(request.maxOutputBytes !== undefined + ? { maxOutputBytes: request.maxOutputBytes } + : {}), + }, + ).pipe( + mapJjFailure( + operation, + input.cwd, + "Could not inspect files outside the Jujutsu snapshot.", + ), + ), + }).pipe( + mapJjFailure( + operation, + input.cwd, + "Could not inspect files outside the Jujutsu snapshot.", + ), + ); + if (unpreserved.hasUnpreservedFiles) + return yield* jjFailure( + operation, + input.cwd, + "Workspace contains untracked or ignored local files that would be lost. Remove it with force to discard them.", + ); + } + + if ( + dirty && + input.force !== true && + !(Option.isSome(change) && change.value.empty && stranded) + ) { + return yield* Effect.fail( + jjFailure( + operation, + input.cwd, + "Workspace has uncommitted or unbookmarked changes. Remove it with force to discard them.", + ), + ); + } + + const segmentBookmark = yield* resolveWorkspaceRefName(driver, input.path, segment); + if ( + stranded && + segmentBookmark !== null && + workspaceNameForRef(segmentBookmark) === matched.name + ) { + // `jj workspace add -r ` leaves the bookmark on the base, so the agent's own + // commits sit on no ref. Moving the bookmark to `@-` first is the jj spelling of what + // git's worktree branch does for free. Preservation must finish before removing the workspace. + yield* bookmarkTo({ + cwd: input.path, + name: segmentBookmark, + revset: "@-", + operation, + }); + } else if (stranded) { + return yield* jjFailure( + operation, + input.cwd, + "Committed workspace changes have no recoverable bookmark. Create one before removing the workspace.", + ); + } + + yield* forgetWorkspace(operation, paths.mainWorkspaceRoot, matched.name).pipe( + mapJjFailure(operation, input.cwd, `Could not forget workspace ${matched.name}.`), + ); + yield* fileSystem + .remove(input.path, { recursive: true }) + .pipe(mapJjFailure(operation, input.cwd, `Could not delete ${input.path}.`)); + }, + ); + + const pruneWorktrees: JjWorkspaceOps["pruneWorktrees"] = Effect.fn("JjWorkspaces.pruneWorktrees")( + function* (input) { + const operation = "JjWorkspaces.pruneWorktrees"; + const paths = yield* driver + .ensureUsable(operation, input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "This Jujutsu repository cannot be used.")); + const workspaces = yield* driver + .listWorkspaces(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not list Jujutsu workspaces.")); + + const bookmarks = yield* driver + .listBookmarks(input.cwd) + .pipe(mapJjFailure(operation, input.cwd, "Could not read recreation bookmarks.")); + for (const workspace of workspaces) { + // The name filter keeps a user's own workspace on an unmounted volume registered; only + // directories T3 created are reclaimed. `default` can never match. + if (!isT3WorkspaceName(workspace.name)) { + continue; + } + const present = + workspace.root !== null && + (yield* fileSystem.exists(workspace.root).pipe(Effect.orElseSucceed(() => false))); + if (present) { + continue; + } + const bookmark = bookmarks.find( + (entry) => entry.remote === null && workspaceNameForRef(entry.name) === workspace.name, + ); + if (bookmark === undefined) continue; + yield* preserveRegisteredWorkspace(operation, input.cwd, workspace.name, bookmark.name); + yield* forgetWorkspace(operation, paths.mainWorkspaceRoot, workspace.name).pipe( + mapJjFailure(operation, input.cwd, "Could not deregister the missing workspace."), + ); + } + }, + ); + + return { createWorktree, removeWorktree, pruneWorktrees }; +}; + +/** + * The per-thread PR base and upstream, written where git records them. A jj bookmark is + * `refs/heads/` in the colocated store, so the whole git-side PR machinery reads these back. + * The upstream keys go on the thread's own bookmark, not on the base it was cut from: they are what + * `resolveBranchHeadContext` reads to decide whether a pull request head is cross-repository. + */ +const writeWorktreeGitConfig = Effect.fn("JjWorkspaces.writeWorktreeGitConfig")(function* (input: { + readonly process: VcsProcess.VcsProcess["Service"]; + readonly gitDir: string | null; + readonly cwd: string; + readonly targetRef: string; + readonly upstreamRemoteName?: string; + readonly newRefName?: string; + readonly baseRefName?: string; +}): Effect.fn.Return { + const gitDir = input.gitDir; + if (gitDir === null) { + return; + } + if (input.upstreamRemoteName !== undefined) { + yield* writeBookmarkUpstreamConfig(input.process, { + gitDir, + cwd: input.cwd, + name: input.targetRef, + remoteName: input.upstreamRemoteName, + }); + } + if (input.newRefName === undefined || input.baseRefName === undefined) { + return; + } + yield* JjProcess.colocatedGitCommand( + input.process, + "JjWorkspaces.writeWorktreeGitConfig", + { gitDir, cwd: input.cwd }, + ["config", `branch.${input.newRefName}.gh-merge-base`, input.baseRefName], + { allowNonZeroExit: true, timeoutMs: 10_000 }, + ).pipe(Effect.ignore); +}); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 4ee97b35df40..02a2773610ee 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -183,6 +183,32 @@ const make = Effect.gen(function* () { ), ); + const reportCaptureExclusions = Effect.fn("CheckpointReactor.reportCaptureExclusions")(function* ( + input: { + readonly threadId: ThreadId; + readonly turnId: TurnId | null; + readonly createdAt: string; + }, + exclusions: ReadonlyArray, + ) { + if (exclusions.length === 0) return; + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: yield* serverCommandId("checkpoint-exclusions"), + threadId: input.threadId, + activity: { + id: yield* serverEventId, + tone: "error", + kind: "checkpoint.capture.excluded", + summary: "Some files are outside this checkpoint and will not be rewound", + payload: { detail: exclusions.join("\n"), exclusions }, + turnId: input.turnId, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }); + const resolveSessionRuntimeForThread = Effect.fn("resolveSessionRuntimeForThread")(function* ( threadId: ThreadId, ): Effect.fn.Return> { @@ -238,7 +264,7 @@ const make = Effect.gen(function* () { if (!cwd) { return undefined; } - if (!(yield* checkpointStore.isGitRepository(cwd))) { + if (!(yield* checkpointStore.supportsCheckpoints(cwd))) { return undefined; } return cwd; @@ -287,10 +313,14 @@ const make = Effect.gen(function* () { }); } - yield* checkpointStore.captureCheckpoint({ + const capture = yield* checkpointStore.captureCheckpoint({ cwd: input.cwd, checkpointRef: targetCheckpointRef, }); + yield* reportCaptureExclusions( + { threadId: input.threadId, turnId: input.turnId, createdAt: input.createdAt }, + capture.exclusions, + ); // Refresh the workspace entry index so the @-mention file picker // reflects files created or deleted during this turn. @@ -499,10 +529,14 @@ const make = Effect.gen(function* () { return; } - yield* checkpointStore.captureCheckpoint({ + const capture = yield* checkpointStore.captureCheckpoint({ cwd: checkpointCwd, checkpointRef: baselineCheckpointRef, }); + yield* reportCaptureExclusions( + { threadId: thread.id, turnId, createdAt: event.createdAt }, + capture.exclusions, + ); yield* receiptBus.publish({ type: "checkpoint.baseline.captured", threadId: thread.id, @@ -714,10 +748,14 @@ const make = Effect.gen(function* () { return; } - yield* checkpointStore.captureCheckpoint({ + const capture = yield* checkpointStore.captureCheckpoint({ cwd: checkpointCwd, checkpointRef: baselineCheckpointRef, }); + yield* reportCaptureExclusions( + { threadId, turnId: null, createdAt: event.occurredAt }, + capture.exclusions, + ); yield* receiptBus.publish({ type: "checkpoint.baseline.captured", threadId, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 2669f3d13752..db20676187a8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -514,20 +514,19 @@ const make = Effect.gen(function* () { Effect.map((settings) => settings.worktreeSubmodules), Effect.orElseSucceed(() => null), ); - yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( - Effect.andThen( - gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath }, { submodules }), - ), - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (cause) => - Effect.logWarning("provider command reactor failed to recreate worktree", { - threadId: thread.id, - worktreePath, - cause: Cause.pretty(cause), - }), - ), - ); + yield* gitWorkflow + .ensureWorkspace({ cwd, refName: branch, path: worktreePath }, { submodules }) + .pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider command reactor failed to recreate worktree", { + threadId: thread.id, + worktreePath, + cause: Cause.pretty(cause), + }), + ), + ); }); const resolveThreadShell = Effect.fnUntraced(function* (threadId: ThreadId) { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index c78cf50feb2a..8f3eccca726a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -270,7 +270,7 @@ describe("ProviderRuntimeIngestion", () => { serverSettings?: Partial; threadTitle?: string; workspaceSubdirectory?: string; - isGitRepository?: CheckpointStore.CheckpointStore["Service"]["isGitRepository"]; + supportsCheckpoints?: CheckpointStore.CheckpointStore["Service"]["supportsCheckpoints"]; }) { const repositoryRoot = makeTempDir("t3-provider-project-"); NodeChildProcess.execFileSync("git", ["init", "--initial-branch=main"], { @@ -336,7 +336,7 @@ describe("ProviderRuntimeIngestion", () => { CheckpointStore.CheckpointStore, Effect.map(CheckpointStore.CheckpointStore, (store) => ({ ...store, - isGitRepository: options?.isGitRepository ?? store.isGitRepository, + supportsCheckpoints: options?.supportsCheckpoints ?? store.supportsCheckpoints, })), ).pipe(Layer.provide(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer)))), ), @@ -4164,7 +4164,7 @@ describe("ProviderRuntimeIngestion", () => { const releaseDetection = yield* Deferred.make(); const harness = yield* Effect.promise(() => createHarness({ - isGitRepository: () => + supportsCheckpoints: () => Deferred.succeed(detectionStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseDetection)), ), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 072ac5110b9d..a71341a1224d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2698,7 +2698,7 @@ const make = Effect.gen(function* () { .getThreadCheckpointContext(event.threadId) .pipe(Effect.map(Option.getOrUndefined)); const workspaceCwd = checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot; - if (!workspaceCwd || !(yield* checkpointStore.isGitRepository(workspaceCwd))) return; + if (!workspaceCwd || !(yield* checkpointStore.supportsCheckpoints(workspaceCwd))) return; yield* worker.enqueue({ source: "diff", event }); }); const diffWorker = yield* makeDrainableWorker((event: ProviderDiffEvent) => diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts index d6572083dad2..4d0ea1ecbdac 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -27,7 +27,8 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; -import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; +import { type GitBranchPullRequest } from "../git/GitManager.ts"; +import { GitWorkflowService as GitManager } from "../git/GitWorkflowService.ts"; import { PullRequestService } from "../pullRequest/PullRequestService.ts"; import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; import { ServerActivation } from "../serverActivation.ts"; diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index 17efc74e4f40..3b5b6a8e989e 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -20,7 +20,8 @@ import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as GitManager from "../git/GitManager.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import type { GitBranchPullRequest } from "../git/GitManager.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import { forkParked } from "../serverActivation.ts"; @@ -58,7 +59,7 @@ interface RefreshRequest { } export function pullRequestMatchesProject( - pullRequest: GitManager.GitBranchPullRequest, + pullRequest: GitBranchPullRequest, project: OrchestrationProjectShell, ): boolean { return ( @@ -73,7 +74,7 @@ export function pullRequestMatchesProject( export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; - const git = yield* GitManager.GitManager; + const git = yield* GitWorkflowService.GitWorkflowService; const pullRequests = yield* PullRequestService.PullRequestService; const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const crypto = yield* Crypto.Crypto; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 07afbaf05e6a..2d1e58798b4f 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -30,7 +30,8 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; -import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; +import { type GitBranchPullRequest } from "../git/GitManager.ts"; +import { GitWorkflowService as GitManager } from "../git/GitWorkflowService.ts"; import { PullRequestService, type PullRequestMergeEvent, @@ -51,7 +52,7 @@ import { ServerConfig } from "../config.ts"; import * as StorageCleanup from "../storageCleanup.ts"; import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { TerminalManager } from "../terminal/Manager.ts"; -import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import { ThreadDeletionReactor } from "./Services/ThreadDeletionReactor.ts"; import { ProviderService } from "../provider/Services/ProviderService.ts"; @@ -1698,15 +1699,6 @@ describe("storage cleanup", () => { ), ), }), - Layer.mock(GitManager)({ - invalidateStatus: () => Effect.void, - branchPullRequest: (_input, options) => { - assert.strictEqual(options?.refresh, true); - return Effect.succeed( - makeBranchPullRequest(protection === "unmerged" ? "open" : "merged"), - ); - }, - }), Layer.mock(OrchestrationEngineService)({ subscribeDomainEvents: PubSub.subscribe(domainEvents).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), @@ -1738,63 +1730,40 @@ describe("storage cleanup", () => { : [], ), }), - Layer.mock(GitVcsDriver)({ - resolvePrimaryRemoteName: () => Effect.succeed("origin"), - resolveDefaultBranchName: () => Effect.succeed("main"), - fetchRemoteTrackingBranch: (input) => + Layer.mock(GitWorkflowService)({ + workspaceIntegrationBase: ({ refresh }) => Effect.sync(() => { - assert.deepStrictEqual(input, { - cwd: config.baseDir, - remoteName: "origin", - remoteBranch: "main", - }); - defaultRefFetched = true; - fetches++; + if (refresh) { + defaultRefFetched = true; + fetches++; + } + return (defaultRefFetched ? "b" : "d").repeat(40); }), - resolveCommit: ({ revision }) => - Effect.sync(() => { - if (revision !== "HEAD") - return { commitSha: (defaultRefFetched ? "b" : "d").repeat(40) }; + isRevisionAncestor: ({ to }) => + Effect.succeed(protection !== "diverged" && to === "b".repeat(40)), + branchPullRequest: () => + Effect.succeed( + makeBranchPullRequest(protection === "unmerged" ? "open" : "merged"), + ), + invalidateStatus: () => Effect.void, + inspectWorkspaceForCleanup: (cwd) => + Effect.gen(function* () { headReads++; + const marker = yield* fs.stat(path.join(cwd, ".git")).pipe(Effect.orDie); return { - commitSha: + isSecondary: marker.type === "File", + refName: cwd === secondWorktreePath ? "feature-two" : "feature", + revision: protection === "head-moved" && headReads > 1 ? "c".repeat(40) : "a".repeat(40), + hasWorkingTreeChanges: + protection === "dirty" || protection === "deleted-dirty", + hasUnpreservedFiles: + protection === "ignored" || + protection === "deleted-ignored" || + protection === "ignored-directory", }; - }), - statusDetailsLocal: (cwd) => - Effect.succeed({ - isRepo: true, - hasOriginRemote: false, - isDefaultBranch: false, - branch: cwd === secondWorktreePath ? "feature-two" : "feature", - upstreamRef: null, - hasWorkingTreeChanges: - protection === "dirty" || protection === "deleted-dirty", - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - aheadOfDefaultCount: 0, - }), - execute: (input) => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode( - input.operation === "StorageCleanup.integratedBranch" && - (protection === "diverged" || input.args.at(-1) !== "b".repeat(40)) - ? 1 - : 0, - ), - stdout: - protection === "ignored" || protection === "deleted-ignored" - ? ".env\0" - : protection === "ignored-directory" - ? ".cache/\0" - : "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, }).pipe( Effect.tap(() => (protection.startsWith("policy-") || @@ -1915,9 +1884,15 @@ describe("storage cleanup", () => { assert.strictEqual(yield* fs.exists(activeLog), true); }).pipe( Effect.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-storage-cleanup-" }).pipe( - Layer.provideMerge(NodeServices.layer), - ), + Layer.unwrap( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-storage-cleanup-", + }); + return ServerConfig.layerTest(process.cwd(), yield* fs.realPath(directory)); + }), + ).pipe(Layer.provideMerge(NodeServices.layer)), ), Effect.scoped, ), diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 4192896efed5..d09327b56612 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -17,7 +17,7 @@ import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as GitManager from "../git/GitManager.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerSettings from "../serverSettings.ts"; import { forkParked } from "../serverActivation.ts"; @@ -82,7 +82,7 @@ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const settingsService = yield* ServerSettings.ServerSettingsService; - const git = yield* GitManager.GitManager; + const git = yield* GitWorkflowService.GitWorkflowService; const pullRequests = yield* PullRequestService.PullRequestService; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 2438edca8b1b..1ab90cb6311d 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -43,6 +43,7 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn import { ProviderCommandReactor } from "../orchestration/Services/ProviderCommandReactor.ts"; import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts"; import { makeProviderServiceLive } from "../provider/Layers/ProviderService.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers, @@ -891,6 +892,9 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { .pipe(Effect.tap(() => Deferred.succeed(turnSent, undefined))), ); const providerLayer = makeProviderServiceLive().pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ detect: () => Effect.succeed(null) }), + ), Layer.provide( Layer.succeed( ProviderAdapterRegistry, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 75cc23375f41..fde7d4cd60bc 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -983,6 +983,48 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("reads a Jujutsu workspace's remote from the main workspace root", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const base = yield* makeTempDir("t3code-workspace-jj-"); + const main = path.join(base, "project"); + const workspace = path.join(base, "thread"); + + yield* fileSystem.makeDirectory(path.join(main, ".jj", "repo"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(main, ".git"), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(main, ".git", "config"), + '[remote "origin"]\n\turl = git@github.com:pingdotgg/t3code.git\n', + ); + // A secondary workspace's `.jj/repo` is a pointer file, relative to `/.jj`. + yield* fileSystem.makeDirectory(path.join(workspace, ".jj"), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(workspace, ".jj", "repo"), + "../../project/.jj/repo", + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-jj", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect( + result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })), + ).toEqual([ + { + path: workspace, + git: { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" }, + }, + ]); + }), + ); + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 1d2fc0d8c3e0..7edfc403492f 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -52,6 +52,7 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { expandHomePath, resolveWorktreesRoot } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { resolveJjRepoPaths } from "../vcs/JjRepo.ts"; import { createTranscriptJsonReader, createTranscriptJsonSelector, @@ -627,6 +628,9 @@ export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const baseDir = path.resolve(serverConfig.baseDir); const worktreesDir = path.resolve(serverConfig.worktreesDir); + const realWorktreesDir = yield* fileSystem + .realPath(worktreesDir) + .pipe(Effect.orElseSucceed(() => worktreesDir)); // Windows filesystems are case-insensitive, so path prefix checks there // must case fold. const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; @@ -657,7 +661,8 @@ export const make = Effect.gen(function* () { normalizeForWorktreeMatch(baseDir, foldWorktreeCase), ) || isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase) || - isT3ManagedWorktree(candidatePath, configuredWorktreesDir, foldWorktreeCase); + isT3ManagedWorktree(candidatePath, configuredWorktreesDir, foldWorktreeCase) || + isT3ManagedWorktree(candidatePath, realWorktreesDir, foldWorktreeCase); const listDirectory = (directory: string) => fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); @@ -704,9 +709,22 @@ export const make = Effect.gen(function* () { > { const gitPath = path.join(directory, ".git"); const gitStats = yield* statOption(gitPath); - if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const; let gitDir = gitPath; - if (gitStats.value.type !== "Directory") { + if (Option.isNone(gitStats)) { + // A user's own `jj workspace add` directory holds `.jj` and no `.git`; without this it is + // imported with no remote and never groups with its repository. + const jjStats = yield* statOption(path.join(directory, ".jj")); + if (Option.isNone(jjStats) || jjStats.value.type !== "Directory") { + return { _tag: "NotGit" } as const; + } + const jjPaths = yield* resolveJjRepoPaths(fileSystem, path, directory).pipe( + Effect.orElseSucceed(() => null), + ); + if (jjPaths === null) { + return { _tag: "NotGit" } as const; + } + gitDir = path.join(jjPaths.mainWorkspaceRoot, ".git"); + } else if (gitStats.value.type !== "Directory") { const pointer = yield* fileSystem .readFileString(gitPath) .pipe(Effect.orElseSucceed(() => "")); diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index d6ddb0b9263f..ee7b8b7cc40c 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -12,6 +12,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import { TestClock } from "effect/testing"; import * as ProcessRunner from "../processRunner.ts"; +import { createJjRepo, JJ_AVAILABLE, runJj } from "../vcs/testing/JjTestSupport.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); @@ -386,4 +387,56 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ), ), ); + it.effect.skipIf(!JJ_AVAILABLE)( + "resolves a secondary Jujutsu workspace through the main workspace root", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fileSystem.realPath( + yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repository-identity-jj-test-" }), + ); + const root = path.join(base, "project"); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* createJjRepo(root); + yield* git(root, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + yield* fileSystem.writeFileString(path.join(root, "seed.txt"), "seed\n"); + yield* runJj(root, ["commit", "-m", "seed"]); + const workspace = path.join(base, "thread"); + yield* runJj(root, ["workspace", "add", "--name", "t3-thread", workspace]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(workspace); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(root)); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + + it.effect.skipIf(!JJ_AVAILABLE)( + "keeps a Git worktree nested under a Jujutsu root on the Git path", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fileSystem.realPath( + yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repository-identity-nested-" }), + ); + const jjRoot = path.join(base, "outer"); + yield* fileSystem.makeDirectory(jjRoot, { recursive: true }); + yield* createJjRepo(jjRoot); + yield* git(jjRoot, ["remote", "add", "origin", "git@github.com:T3Tools/outer.git"]); + + const nested = path.join(jjRoot, "nested"); + yield* fileSystem.makeDirectory(nested, { recursive: true }); + yield* git(nested, ["init"]); + yield* git(nested, ["remote", "add", "origin", "git@github.com:T3Tools/nested.git"]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(nested); + + expect(identity?.canonicalKey).toBe("github.com/t3tools/nested"); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 2d7f5d02d02e..ef891b01137e 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -1,3 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off - the marker walk is a handful of sync stats beside a +// subprocess this resolver already spawns, and the module deliberately takes no FileSystem or Path +// dependency: its layer is constructed in three places this workstream does not own. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts"; import { detectSourceControlProviderFromGitRemoteUrl, @@ -10,6 +16,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import { mainWorkspaceRootFromRepoPointer } from "../vcs/JjRepo.ts"; import * as ProcessRunner from "../processRunner.ts"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; @@ -93,11 +100,72 @@ function buildRepositoryIdentity(input: { }; } +function statMarker(candidate: string): NodeFS.Stats | null { + try { + return NodeFS.statSync(candidate); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ) { + return null; + } + throw error; + } +} + +/** + * The root of the repository `cwd` belongs to, resolved from the nearest `.jj` or `.git` marker. + * A secondary Jujutsu workspace has no `.git` at all, so `git rev-parse --show-toplevel` returns + * nothing there, and every consumer of the repository identity (pull-request sync, the projector, + * the decider) goes dark with no error surfaced anywhere. + */ +export function resolveRepositoryRootFromMarkers(cwd: string): string | null { + let current = cwd; + for (;;) { + if (statMarker(NodePath.join(current, ".jj"))?.isDirectory()) { + const repoPointer = NodePath.join(current, ".jj", "repo"); + if (statMarker(repoPointer)?.isDirectory()) { + return current; + } + const pointerContents = NodeFS.readFileSync(repoPointer, "utf8"); + try { + return mainWorkspaceRootFromRepoPointer(current, pointerContents); + } catch { + return current; + } + } + if (statMarker(NodePath.join(current, ".git")) !== null) { + return null; + } + const parent = NodePath.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function resolveRepositoryRootFromReadableMarkers(cwd: string): string | null { + try { + return resolveRepositoryRootFromMarkers(cwd); + } catch { + // A marker is unreadable; let git resolve the repository from cwd. + return null; + } +} + const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; - // git is a real executable on every platform — no cmd.exe shell mode, which + const jjRoot = resolveRepositoryRootFromReadableMarkers(cwd); + if (jjRoot !== null) { + return jjRoot; + } + + // git is a real executable on every platform, no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. const topLevelResult = yield* processRunner .run({ diff --git a/apps/server/src/project/WorkspaceSettings.ts b/apps/server/src/project/WorkspaceSettings.ts new file mode 100644 index 000000000000..4c02b257532c --- /dev/null +++ b/apps/server/src/project/WorkspaceSettings.ts @@ -0,0 +1,31 @@ +import { type ProjectId, type ThreadId } from "@t3tools/contracts"; +import { + hasProjectSettingsOverrides, + resolveProjectSettings, +} from "@t3tools/shared/projectSettings"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; + +/** Workspace operations also run without orchestration, where environment settings apply. */ +export const workspaceSettings = Effect.fnUntraced(function* (input: { + readonly cwd: string; + readonly threadId?: ThreadId | undefined; +}) { + const service = yield* ServerSettingsService; + const settings = yield* service.getSettings; + const query = yield* Effect.serviceOption(ProjectionSnapshotQuery.ProjectionSnapshotQuery); + if (!hasProjectSettingsOverrides(settings) || Option.isNone(query)) return settings; + const projectId = yield* ( + input.threadId === undefined + ? query.value + .getActiveProjectByWorkspaceRoot(input.cwd) + .pipe(Effect.map(Option.map((project) => project.id))) + : query.value + .getThreadShellById(input.threadId) + .pipe(Effect.map(Option.map((thread) => thread.projectId))) + ).pipe(Effect.orElseSucceed(() => Option.none())); + return resolveProjectSettings(settings, Option.getOrNull(projectId)).settings; +}); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 2ed593d159bc..b61521e525da 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,6 +1,6 @@ import type { ProviderInteractionMode } from "@t3tools/contracts"; import type { V2TurnStartParams__AdditionalContextEntry } from "effect-codex-app-server/schema"; -import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; +import { buildRuntimeInstructions, buildVcsAgentContext } from "./RuntimeInstructions.ts"; const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `## T3 Code collaborative browser @@ -185,6 +185,7 @@ In Default mode, strongly prefer making reasonable assumptions and executing the `; export interface CodexRuntimeInfo { + readonly vcsAgentHint?: string | null | undefined; readonly model: string; readonly modelName?: string | undefined; readonly reasoningEffort: string; @@ -220,8 +221,22 @@ export function buildCodexAdditionalContext( return { t3_code_runtime: { kind: "application", - value: buildRuntimeInstructions({ harness: "Codex", ...runtime }), + value: buildRuntimeInstructions({ + harness: "Codex", + model: runtime.model, + modelName: runtime.modelName, + reasoningEffort: runtime.reasoningEffort, + }), }, ...(tools ? { t3_code_tools: { kind: "application", value: tools } } : {}), + ...codexVcsContext(runtime.vcsAgentHint), }; } + +export function codexVcsContext( + hint: string | null | undefined, +): Record { + return hint + ? { t3_vcs_context: { kind: "application", value: buildVcsAgentContext(hint).trim() } } + : {}; +} diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts index cd245570d1ad..d0ad2bcbcf20 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -1267,7 +1267,9 @@ it.layer(layer)("AntigravityAdapter", (it) => { const path = yield* Path.Path; const h = yield* makeHarness(); const { attachmentsDir } = yield* ServerConfig; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-fs-" }); + const cwd = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-agy-fs-" }) + .pipe(Effect.flatMap(fs.realPath)); const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-outside-" }); yield* fs.writeFileString(path.join(cwd, "notes.txt"), "one\ntwo\nthree\n"); yield* h.adapter.startSession({ threadId, cwd, runtimeMode: "approval-required" }); diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index e12a320c4d48..d0e2e1a2cc39 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -1085,7 +1085,11 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi ...prompt, { type: "text", - text: buildRuntimeInstructions({ harness: "Antigravity", model }), + text: buildRuntimeInstructions({ + harness: "Antigravity", + model, + vcsAgentHint: input.vcsAgentHint, + }), }, ], }, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6e30f5241780..431e6814cb12 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -93,7 +93,7 @@ import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { claudeSignedOutMessage, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; -import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; +import { buildRuntimeInstructions, buildVcsAgentContext } from "../RuntimeInstructions.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, @@ -1606,6 +1606,8 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( ) { const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog); const sdkContent: Array> = []; + const vcsContext = buildVcsAgentContext(input.vcsAgentHint); + if (vcsContext) sdkContent.push({ type: "text", text: vcsContext.trim() }); // Claude Code expands a skill only from the LAST text block, and only when // `/name` is its first character. A `$skill` chip anywhere in the prompt is diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index baa8d846f7c6..0a4e3f3ed5e2 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2280,6 +2280,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), binaryPath: codexConfig.binaryPath, + ...(input.agentContextHint ? { agentContextHint: input.agentContextHint } : {}), ...(options?.models ? { models: options.models } : {}), launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), ...(options?.environment ? { environment: options.environment } : {}), @@ -2541,6 +2542,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( : undefined; return yield* session.runtime .sendTurn({ + ...(input.vcsAgentHint !== undefined ? { vcsAgentHint: input.vcsAgentHint } : {}), ...(input.input !== undefined ? { input: input.input } : {}), ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 5405169926f5..546f2fe62316 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -870,74 +870,87 @@ describe("CodexSessionRuntime collab integration", () => { }); describe("CodexSessionRuntime compaction", () => { - it.effect("restores T3 context after the root thread compacts", () => - Effect.gen(function* () { - const compacted = (threadId: string) => ({ - method: "item/completed", - params: { - threadId, - turnId: `${threadId}-turn`, - completedAtMs: 0, - item: { type: "contextCompaction", id: `compaction-${threadId}` }, - }, - }); - const script = { - rootThreadId: ROOT, - recordRequests: true, - // A child's compaction must not inject into the root thread. - notifications: [compacted(CHILD_A), compacted(ROOT)], - }; - // @effect-diagnostics-next-line preferSchemaOverJson:off - NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); - NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - NodeFS.rmSync(scriptPath, { force: true }); + for (const enabled of [true, false]) { + it.effect( + `restores T3 context after compaction with current VCS hints ${enabled ? "enabled" : "disabled"}`, + () => + Effect.gen(function* () { + const compacted = (threadId: string) => ({ + method: "item/completed", + params: { + threadId, + turnId: `${threadId}-turn`, + completedAtMs: 0, + item: { type: "contextCompaction", id: `compaction-${threadId}` }, + }, + }); + const script = { + rootThreadId: ROOT, + recordRequests: true, + // A child's compaction must not inject into the root thread. + notifications: [compacted(CHILD_A), compacted(ROOT)], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); - }), - ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); - const runtime = yield* makeCodexSessionRuntime({ - threadId: ThreadId.make("thread-compaction-context"), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "full-access", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - models: Effect.succeed([ - { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", isCustom: false, capabilities: null }, - ]), - }); - const completedFiber = yield* runtime.events.pipe( - Stream.filter((event) => event.method === "turn/completed"), - Stream.take(1), - Stream.runCollect, - Effect.forkScoped, - ); + const runtime = yield* makeCodexSessionRuntime({ + agentContextHint: Effect.succeed(enabled ? "This workspace uses Jujutsu." : null), + threadId: ThreadId.make("thread-compaction-context"), + binaryPath: peerPath, + cwd: NodeOS.tmpdir(), + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + models: Effect.succeed([ + { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", isCustom: false, capabilities: null }, + ]), + }); + const completedFiber = yield* runtime.events.pipe( + Stream.filter((event) => event.method === "turn/completed"), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); - yield* runtime.start(); - yield* runtime.sendTurn({ input: "keep going", interactionMode: "default" }); - yield* Fiber.join(completedFiber); - - // The restore is awaited before later notifications, so it has landed. - const requests = readRecordedRequests(); - assert.lengthOf(requests, 1); - const [inject] = requests; - assert.isDefined(inject); - assert.equal(inject.method, "thread/inject_items"); - assert.equal(inject.params.threadId, ROOT); - const texts = ( - inject.params.items as ReadonlyArray<{ role: string; content: [{ text: string }] }> - ).map((item) => { - assert.equal(item.role, "developer"); - return item.content[0].text; - }); - assert.lengthOf(texts, 1); - assert.match( - texts[0] ?? "", - /^.*as GPT-5\.6 Sol \(model slug: gpt-5\.6-sol\).*<\/t3_code_runtime>$/s, - ); + yield* runtime.start(); + yield* runtime.sendTurn({ + input: "keep going", + interactionMode: "default", + vcsAgentHint: "This workspace uses Jujutsu.", + }); + yield* Fiber.join(completedFiber); + + // The restore is awaited before later notifications, so it has landed. + const requests = readRecordedRequests(); + assert.lengthOf(requests, 1); + const [inject] = requests; + assert.isDefined(inject); + assert.equal(inject.method, "thread/inject_items"); + assert.equal(inject.params.threadId, ROOT); + const texts = ( + inject.params.items as ReadonlyArray<{ role: string; content: [{ text: string }] }> + ).map((item) => { + assert.equal(item.role, "developer"); + return item.content[0].text; + }); + assert.lengthOf(texts, enabled ? 2 : 1); + assert.equal( + texts.some((text) => text.includes("Jujutsu")), + enabled, + ); + assert.match( + texts[0] ?? "", + /^.*as GPT-5\.6 Sol \(model slug: gpt-5\.6-sol\).*<\/t3_code_runtime>$/s, + ); - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + } }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index f30baf011e8a..7d0e42f9f6b8 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -43,6 +43,7 @@ import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexAdditionalContext, buildCodexDeveloperInstructions, + codexVcsContext, type T3CodeToolAvailability, } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); @@ -171,6 +172,7 @@ type CodexThreadItem = EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number]; export interface CodexSessionRuntimeOptions { + readonly agentContextHint?: Effect.Effect; readonly threadId: ThreadId; readonly providerInstanceId?: ProviderInstanceId; readonly binaryPath: string; @@ -190,6 +192,7 @@ export interface CodexSessionRuntimeOptions { } export interface CodexSessionRuntimeSendTurnInput { + readonly vcsAgentHint?: string | null | undefined; readonly input?: string; readonly attachments?: ReadonlyArray<{ readonly type: "localImage"; @@ -587,6 +590,7 @@ function runtimeModeToTurnSandboxPolicy( } function buildCodexTurnInstructions(input: { + readonly vcsAgentHint?: string | null | undefined; readonly interactionMode?: ProviderInteractionMode; readonly model?: string; readonly modelName?: string; @@ -608,7 +612,7 @@ function buildCodexTurnInstructions(input: { }, }, additionalContext: buildCodexAdditionalContext( - { model, modelName: input.modelName, reasoningEffort }, + { model, modelName: input.modelName, reasoningEffort, vcsAgentHint: input.vcsAgentHint }, input.browserToolsAvailable ?? true, ), }; @@ -619,6 +623,7 @@ const SKILL_MENTION_PATTERN = /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; export function buildTurnStartParams(input: { + readonly vcsAgentHint?: string | null | undefined; readonly threadId: string; readonly runtimeMode: RuntimeMode; readonly prompt?: string; @@ -651,6 +656,7 @@ export function buildTurnStartParams(input: { const config = runtimeModeToThreadConfig(input.runtimeMode); const turnInstructions = buildCodexTurnInstructions({ + vcsAgentHint: input.vcsAgentHint, ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), ...(input.model ? { model: input.model } : {}), ...(input.modelName ? { modelName: input.modelName } : {}), @@ -1882,8 +1888,13 @@ export const makeCodexSessionRuntime = ( */ const restoreAdditionalContext = (threadId: string) => Effect.gen(function* () { - const context = yield* Ref.get(lastAdditionalContextRef); - if (!context) return; + const previous = yield* Ref.get(lastAdditionalContextRef); + if (!previous) return; + const stableContext = { ...previous }; + delete stableContext.t3_vcs_context; + const context = options.agentContextHint + ? { ...stableContext, ...codexVcsContext(yield* options.agentContextHint) } + : previous; yield* client.request("thread/inject_items", { threadId, items: Object.entries(context).map(([key, entry]) => ({ @@ -2565,6 +2576,7 @@ export const makeCodexSessionRuntime = ( const models = options.models ? yield* options.models : []; const modelName = models.find((model) => model.slug === normalizedModel)?.name; const params = yield* buildTurnStartParams({ + vcsAgentHint: input.vcsAgentHint, threadId: providerThreadId, runtimeMode: options.runtimeMode, ...(input.input ? { prompt: input.input } : {}), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index e0fe5016326d..1777de6beaa5 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1097,7 +1097,11 @@ export function makeCursorAdapter( ...promptParts, { type: "text", - text: buildRuntimeInstructions({ harness: "Cursor", model: resolvedModel }), + text: buildRuntimeInstructions({ + harness: "Cursor", + model: resolvedModel, + vcsAgentHint: input.vcsAgentHint, + }), }, ], }) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 601ce9ffa37e..4d0e814f7573 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -1644,6 +1644,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ? undefined : buildRuntimeInstructions({ harness: "Grok", + vcsAgentHint: input.vcsAgentHint, model: displayModel, reasoningEffort: normalizeGrokReasoningEffort(requestedTurnReasoningEffort), }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8bf9229aa86c..6a8c967d09bb 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -3278,6 +3278,7 @@ export function makeOpenCodeAdapter( value: buildRuntimeInstructions({ harness: "OpenCode", model: `${parsedModel.providerID}/${parsedModel.modelID}`, + vcsAgentHint: input.vcsAgentHint, }), }, { signal }, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 1c7bd24a1819..70c3c9759520 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -78,11 +78,15 @@ import * as ServerSettings from "../../serverSettings.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( Layer.provide(NodeServices.layer), + Layer.merge( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ detect: () => Effect.succeed(null) }), + ), ); // startSession verifies the workspace folder exists before dispatching to an @@ -1616,6 +1620,7 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.deepEqual(routing.codex.sendTurn.mock.calls.at(-1)?.[0], { threadId: codexThreadId, continuation: true, + vcsAgentHint: null, }); const claudeThreadId = asThreadId("thread-promptless-continuation-unsupported"); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 82e657803673..a5a74c42cdac 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -86,6 +86,7 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; const isModelSelection = Schema.is(ModelSelection); const encodePromptJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -479,9 +480,26 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const serverSettings = yield* ServerSettings.ServerSettingsService; + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const projectionQuery = yield* Effect.serviceOption( ProjectionSnapshotQuery.ProjectionSnapshotQuery, ); + const resolveAgentContextHint = Effect.fnUntraced(function* (threadId: ThreadId, cwd?: string) { + let settings = yield* serverSettings.getSettings.pipe(Effect.orDie); + if (Option.isSome(projectionQuery)) { + const thread = yield* projectionQuery.value + .getThreadShellById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isSome(thread)) + settings = resolveProjectSettings(settings, thread.value.projectId).settings; + } + return settings.enableVcsAgentHints && cwd + ? yield* vcsRegistry.detect({ cwd }).pipe( + Effect.map((handle) => handle?.driver.agentContextHint ?? null), + Effect.orElseSucceed(() => null), + ) + : null; + }); const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; const fileSystem = yield* FileSystem.FileSystem; @@ -1274,6 +1292,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const resumed = yield* adapter .startSession({ threadId: input.binding.threadId, + agentContextHint: resolveAgentContextHint(input.binding.threadId, persistedCwd), provider: input.binding.provider, providerInstanceId: bindingInstanceId, ...(persistedCwd ? { cwd: persistedCwd } : {}), @@ -1505,6 +1524,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const session = yield* adapter .startSession({ ...input, + agentContextHint: resolveAgentContextHint(threadId, effectiveCwd), providerInstanceId: resolvedInstanceId, ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), @@ -1733,7 +1753,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }), (turnMetadata) => Effect.gen(function* () { - const turn = yield* routed.adapter.sendTurn(input); + const binding = yield* directory.getBinding(input.threadId); + const cwd = Option.isSome(binding) + ? readPersistedCwd(binding.value.runtimePayload) + : undefined; + const hint = yield* resolveAgentContextHint(input.threadId, cwd); + const turn = yield* routed.adapter.sendTurn({ ...input, vcsAgentHint: hint }); yield* associateTurnAnalytics({ providerInstanceId: routed.instanceId, threadId: input.threadId, diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index afaba915d785..c051a3bb8ed3 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -11,6 +11,7 @@ export function buildRuntimeInstructions(runtime: { readonly model?: string | undefined; readonly modelName?: string | undefined; readonly reasoningEffort?: string | undefined; + readonly vcsAgentHint?: string | null | undefined; }): string { const harness = toSingleLine(runtime.harness); const model = toSingleLine(runtime.model ?? ""); @@ -20,7 +21,11 @@ export function buildRuntimeInstructions(runtime: { modelName && modelName !== model ? `${modelName} (model slug: ${model})` : model; const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${modelLabel}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; - return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; + return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}${buildVcsAgentContext(runtime.vcsAgentHint)}`; +} + +export function buildVcsAgentContext(hint: string | null | undefined): string { + return hint ? `\n\n${hint}` : ""; } function toSingleLine(value: string): string { diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index c9b62fd79525..5932a017563e 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -75,7 +75,10 @@ export interface ProviderAdapterShape { * Start a provider-backed session. */ readonly startSession: ( - input: ProviderSessionStartInput, + input: ProviderSessionStartInput & { + /** Re-read ephemeral context when the harness regenerates it without starting a turn. */ + readonly agentContextHint?: Effect.Effect; + }, ) => Effect.Effect; /** diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index e4d345e80dac..601d4e839090 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -1,22 +1,59 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import type { ReviewDiffFileContentsResult, VcsDriverKind } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../config.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import type * as VcsDriver from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as ReviewService from "./ReviewService.ts"; +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + +function makeDriver( + kind: VcsDriverKind, + overrides: Partial, +): VcsDriver.VcsDriver["Service"] { + return { + capabilities: { + kind, + supportsWorktrees: kind === "git", + supportsBookmarks: kind === "jj", + supportsAtomicSnapshot: kind === "jj", + supportsPushDefaultRemote: kind === "git", + ignoreClassifier: "git-compatible-fallback", + }, + execute: () => Effect.die("unexpected driver execute"), + detectRepository: () => Effect.die("unexpected driver detectRepository"), + isInsideWorkTree: () => Effect.die("unexpected driver isInsideWorkTree"), + listWorkspaceFiles: () => Effect.die("unexpected driver listWorkspaceFiles"), + listRemotes: () => Effect.die("unexpected driver listRemotes"), + filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), + initRepository: () => Effect.die("unexpected driver initRepository"), + ...overrides, + }; +} + function makeLayer(input: { readonly workspaceRoot: string; readonly baseDir: string; readonly detectCalls?: Array<{ readonly cwd: string }>; readonly worktreeDirectory?: string; + readonly handle?: { + readonly kind: VcsDriverKind; + readonly driver: VcsDriver.VcsDriver["Service"]; + }; + readonly gitFileContents?: Effect.Effect; }) { + const handle = input.handle; + const gitFileContents = input.gitFileContents; return ReviewService.layer.pipe( Layer.provide( ServerSettingsService.layerTest({ worktreeDirectory: input.worktreeDirectory ?? "" }), @@ -28,11 +65,30 @@ function makeLayer(input: { detect: (request) => Effect.sync(() => { input.detectCalls?.push({ cwd: request.cwd }); - return null; + return handle + ? ({ + kind: handle.kind, + repository: { + kind: handle.kind, + rootPath: request.cwd, + metadataPath: null, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }, + driver: handle.driver, + } satisfies VcsDriverRegistry.VcsDriverHandle) + : null; }), }), ), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide( + Layer.mock(GitVcsDriver.GitVcsDriver)( + gitFileContents ? { getReviewDiffFileContents: () => gitFileContents } : {}, + ), + ), Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)), Layer.provideMerge(NodeServices.layer), ); @@ -155,4 +211,76 @@ describe("ReviewService", () => { assert.deepStrictEqual(detectCalls, []); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect.each([ + { + name: "expands unchanged lines through a driver that implements it", + kind: "jj" as const, + driverContents: { oldContents: "from the driver\n", newContents: "driver\n" }, + gitContents: null, + expected: { oldContents: "from the driver\n", newContents: "driver\n" }, + }, + { + name: "still reaches the Git implementation when the driver has no expansion", + kind: "git" as const, + driverContents: null, + gitContents: { oldContents: "from git\n", newContents: "git\n" }, + expected: { oldContents: "from git\n", newContents: "git\n" }, + }, + { + name: "refuses expansion when neither the driver nor Git can serve it", + kind: "jj" as const, + driverContents: null, + gitContents: null, + expected: null, + }, + ])("$name", (scenario) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const driver = makeDriver( + scenario.kind, + scenario.driverContents === null + ? {} + : { getDiffFileContents: () => Effect.succeed(scenario.driverContents) }, + ); + + const run = Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffFileContents({ + cwd: workspaceRoot, + sourceKind: "working-tree", + changeType: "change", + baseRef: "@-", + headRef: "@", + oldPath: "file.ts", + newPath: "file.ts", + }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + handle: { kind: scenario.kind, driver }, + gitFileContents: + scenario.gitContents === null + ? Effect.die("unexpected Git review diff file contents") + : Effect.succeed(scenario.gitContents), + }), + ), + ); + + if (scenario.expected === null) { + const error = yield* Effect.flip(run); + assert.strictEqual(error._tag, "VcsUnsupportedOperationError"); + assert.strictEqual( + "detail" in error ? error.detail : "", + "Unchanged diff expansion is not available for this version control system.", + ); + return; + } + assert.deepStrictEqual(yield* run, scenario.expected); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index fbf8ed077d44..5c0f6023dbd5 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -133,15 +133,19 @@ export const make = Effect.gen(function* () { yield* assertWorkspaceBoundCwd("ReviewService.getDiffFileContents", input.cwd); const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" }); - if (handle?.kind !== "git") { - return yield* new VcsUnsupportedOperationError({ - operation: "ReviewService.getDiffFileContents", - kind: handle?.kind ?? "unknown", - detail: "Unchanged diff expansion currently requires a Git repository.", - }); + const driverGetDiffFileContents = handle?.driver.getDiffFileContents; + if (driverGetDiffFileContents) { + return yield* driverGetDiffFileContents(input); + } + if (handle?.kind === "git") { + return yield* git.getReviewDiffFileContents(input); } - return yield* git.getReviewDiffFileContents(input); + return yield* new VcsUnsupportedOperationError({ + operation: "ReviewService.getDiffFileContents", + kind: handle?.kind ?? "unknown", + detail: "Unchanged diff expansion is not available for this version control system.", + }); }); return ReviewService.of({ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 313701473f0f..bab055c6b2a3 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -174,6 +174,7 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as JjWorkflow from "./jj/JjWorkflow.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -651,6 +652,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.vcsDriver, }; const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + invalidate: () => Effect.void, get: () => Effect.succeed(defaultVcsDriver), detect: (input) => defaultVcsDriver.detectRepository(input.cwd).pipe( @@ -731,6 +733,13 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(vcsDriverRegistryLayer), Layer.provideMerge(gitVcsDriverLayer), Layer.provideMerge(gitManagerLayer), + Layer.provideMerge( + Layer.mock(JjWorkflow.JjWorkflow)({ + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + }), + ), ); const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( Layer.provide(vcsDriverRegistryLayer), @@ -8095,6 +8104,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), }, gitVcsDriver: { + execute: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "/tmp/repo/.git\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), pullCurrentBranch: () => Effect.succeed({ status: "pulled", @@ -8317,6 +8334,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { let statusCalls = 0; yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { pullCurrentBranch: () => Effect.fail(gitError), }, @@ -8397,6 +8417,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { let statusCalls = 0; yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitManager: { invalidateLocalStatus: () => Effect.sync(() => { @@ -8471,6 +8494,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { pullCurrentBranch: () => Effect.succeed({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ab0b94198db9..60658ce68a35 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -111,6 +111,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as JjWorkflow from "./jj/JjWorkflow.ts"; +import * as JjVcsDriver from "./vcs/JjVcsDriver.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; @@ -280,17 +282,18 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( // `create()`; `ProviderEventLoggers.layer` owns the shared native/canonical // NDJSON writers and is provided at the outer runtime layer so both // `ProviderService` and the per-instance drivers read the same logger pair. -const ProviderLayerLive = ProviderServiceLive.pipe( - Layer.provide(ProviderAdapterRegistryLive), - Layer.provideMerge(ProviderSessionDirectoryLayerLive), -); - const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); +const ProviderLayerLive = ProviderServiceLive.pipe( + Layer.provide(ProviderAdapterRegistryLive), + Layer.provide(VcsDriverRegistryLayerLive), + Layer.provideMerge(ProviderSessionDirectoryLayerLive), +); + const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( @@ -365,15 +368,25 @@ const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitVcsDriver.layer), ); +const JjWorkflowLayerLive = JjWorkflow.layer.pipe( + Layer.provideMerge(JjVcsDriver.layer), + // Both for `branchPullRequest` and `resolvePullRequest`, which the jj lane runs against the + // colocated Git store. `GitManagerLayerLive` already merges `ProjectSetupScriptRunner`. + Layer.provideMerge(GitManagerLayerLive), +); + const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(VcsDriverRegistryLayerLive), Layer.provideMerge(GitLayerLive), + Layer.provideMerge(JjWorkflowLayerLive), ); const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.layer.pipe( Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), + Layer.provideMerge(VcsDriverRegistryLayerLive), + Layer.provideMerge(JjWorkflowLayerLive), ); const ProjectCloneTrackerLayerLive = ProjectCloneTracker.layer.pipe( diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index f0b4ee3ddbe2..6af3470da92f 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -21,16 +21,16 @@ import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngi import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; -import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () => Effect.gen(function* () { const pulled: string[] = []; const git = { - statusDetails: (cwd: string) => + status: ({ cwd }: { readonly cwd: string }) => Effect.succeed({ isRepo: true, - isDefaultBranch: cwd !== "/feature", + isDefaultRef: cwd !== "/feature", hasUpstream: true, hasWorkingTreeChanges: cwd === "/dirty", aheadCount: cwd === "/ahead" ? 1 : 0, @@ -45,7 +45,7 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che upstreamRef: "origin/main", }; }), - } as unknown as GitVcsDriver.GitVcsDriver["Service"]; + } as unknown as GitWorkflowService.GitWorkflowService["Service"]; const project = (workspaceRoot: string) => ({ id: ProjectId.make(workspaceRoot), workspaceRoot }) as never; const overrides = (entries: Record) => ({ @@ -75,7 +75,7 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che "/feature": true, "/disabled": false, }), - ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + ).pipe(Effect.provideService(GitWorkflowService.GitWorkflowService, git)); assert.deepStrictEqual(pulled, ["/clean"]); @@ -83,7 +83,7 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che yield* ServerRuntimeStartup.autoPullProjects( [project("/inherited"), project("/opted-out"), project("/dirty")], { ...overrides({ "/opted-out": false }), defaultAutoPull: true }, - ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + ).pipe(Effect.provideService(GitWorkflowService.GitWorkflowService, git)); assert.deepStrictEqual(pulled, ["/inherited"]); }), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index ee989e311f05..8c1100fea03b 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -49,7 +49,7 @@ import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDi import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; import { forkParked } from "./serverActivation.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; -import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import { formatHeadlessServeOutput, formatHostForUrl, @@ -863,7 +863,7 @@ export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, settings: ServerSettingsValue = DEFAULT_SERVER_SETTINGS, ) { - const git = yield* GitVcsDriver.GitVcsDriver; + const git = yield* GitWorkflowService.GitWorkflowService; const workspaceRoots = [ ...new Set( projects @@ -876,10 +876,10 @@ export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( workspaceRoots, (cwd) => Effect.gen(function* () { - const status = yield* git.statusDetails(cwd); + const status = yield* git.status({ cwd }); if ( !status.isRepo || - !status.isDefaultBranch || + !status.isDefaultRef || !status.hasUpstream || status.hasWorkingTreeChanges || status.aheadCount > 0 @@ -888,7 +888,7 @@ export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( cwd, reason: !status.isRepo ? "not-a-repository" - : !status.isDefaultBranch + : !status.isDefaultRef ? "not-on-default-branch" : !status.hasUpstream ? "no-upstream" diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 30aa22995b95..91a402f36b81 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -444,7 +444,7 @@ it.effect("reports implemented tools separately from locally available executabl })), [ { kind: "git", implemented: true, status: "available" }, - { kind: "jj", implemented: false, status: "missing" }, + { kind: "jj", implemented: true, status: "missing" }, ], ); assert.deepStrictEqual( diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 2628360780e1..e38877e4e39b 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -52,7 +52,7 @@ const VCS_PROBES: ReadonlyArray = [ label: "Jujutsu", executable: "jj", versionArgs: ["--version"], - implemented: false, + implemented: true, installHint: "Install Jujutsu with `brew install jj` or from https://github.com/jj-vcs/jj.", }, ]; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index ea90320eb8b6..45e82cc9ce31 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -13,6 +13,8 @@ import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts" import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as JjWorkflow from "../jj/JjWorkflow.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; @@ -58,8 +60,16 @@ function makeLayer(input: { readonly provider?: SourceControlProvider.SourceControlProvider["Service"]; readonly git?: Partial; readonly fileSystem?: FileSystem.FileSystem; + readonly detect?: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; + readonly jj?: Partial; }) { const serviceLayer = SourceControlRepositoryService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + detect: input.detect ?? (() => Effect.succeed(null)), + }), + ), + Layer.provide(Layer.mock(JjWorkflow.JjWorkflow)(input.jj ?? {})), Layer.provide( Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ resolveLink: () => undefined, @@ -446,6 +456,53 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up ); }); +it.effect( + "publishes a Jujutsu repository through the bookmark push, not Git's detached HEAD", + () => { + const publishCalls: Array<{ cwd: string; remoteName: string; remoteUrl: string }> = []; + const gitPushes: Array = []; + const provider = makeProvider({ createRepository: () => Effect.succeed(CLONE_URLS) }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + yield* service.publishRepository({ + cwd: "/workspace", + provider: "github", + repository: "octocat/t3code", + visibility: "private", + remoteName: "upstream", + protocol: "ssh", + }); + + assert.deepStrictEqual(publishCalls, [ + { cwd: "/workspace", remoteName: "upstream", remoteUrl: CLONE_URLS.sshUrl }, + ]); + assert.deepStrictEqual(gitPushes, []); + }).pipe( + Effect.provide( + makeLayer({ + provider, + detect: () => + Effect.succeed({ kind: "jj" } as unknown as VcsDriverRegistry.VcsDriverHandle), + jj: { + publishRepository: (input) => + Effect.sync(() => { + publishCalls.push(input); + return { refName: "main", status: "pushed" as const }; + }), + }, + git: { + pushCurrentBranch: (cwd) => + Effect.sync(() => gitPushes.push(cwd)).pipe( + Effect.andThen(Effect.die("the jj path must not reach Git's push")), + ), + }, + }), + ), + ); + }, +); + it.effect("publishes to the remote name returned by ensureRemote", () => { const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 9bb3b1e029b6..a6274f040e0e 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -20,12 +20,14 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; +import * as JjWorkflow from "../jj/JjWorkflow.ts"; import { expandHomePathWith } from "../pathExpansion.ts"; import { parseGitCloneProgressLine, type GitCloneProgressLine, } from "../project/gitCloneProgress.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); @@ -160,6 +162,8 @@ export const make = Effect.gen(function* () { const git = yield* GitVcsDriver.GitVcsDriver; const path = yield* Path.Path; const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + const jjWorkflow = yield* JjWorkflow.JjWorkflow; const ensureConcreteProvider = (input: { readonly operation: string; @@ -398,9 +402,32 @@ export const make = Effect.gen(function* () { visibility: input.visibility, }); const remoteUrl = selectRemoteUrl(urls, input.protocol); + const preferredRemoteName = input.remoteName?.trim() || "origin"; + + const kind = yield* vcsRegistry.detect({ cwd: input.cwd }).pipe( + Effect.map((handle) => handle?.kind ?? null), + Effect.orElseSucceed(() => null), + ); + // In a colocated Jujutsu repository Git's own HEAD is usually detached, so the Git path + // either fails confusingly or publishes `@-`. + if (kind === "jj") { + const published = yield* jjWorkflow.publishRepository({ + cwd: input.cwd, + remoteName: preferredRemoteName, + remoteUrl, + }); + return { + repository: toRepositoryInfo(providerKind, urls), + remoteName: preferredRemoteName, + remoteUrl, + branch: published.refName, + status: published.status, + }; + } + const remoteName = yield* git.ensureRemote({ cwd: input.cwd, - preferredName: input.remoteName?.trim() || "origin", + preferredName: preferredRemoteName, url: remoteUrl, }); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index b910e58578c5..5034b7e1a0ca 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -13,6 +13,7 @@ import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as Equal from "effect/Equal"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -25,7 +26,7 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; import { resolveWorktreesRoot } from "./pathExpansion.ts"; -import * as GitManager from "./git/GitManager.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ThreadDeletionReactor from "./orchestration/Services/ThreadDeletionReactor.ts"; @@ -34,7 +35,6 @@ import { threadHasQueuedTurnStart } from "./orchestration/ThreadSettlementPolicy import { forkParked } from "./serverActivation.ts"; import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; -import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; export class StorageCleanup extends Context.Service< @@ -113,8 +113,7 @@ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const threadDeletion = yield* ThreadDeletionReactor.ThreadDeletionReactor; const providers = yield* ProviderService.ProviderService; - const git = yield* GitVcsDriver.GitVcsDriver; - const gitManager = yield* GitManager.GitManager; + const vcs = yield* GitWorkflowService.GitWorkflowService; const terminals = yield* TerminalManager.TerminalManager; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -202,7 +201,7 @@ export const make = Effect.gen(function* () { yield* threadDeletion.drainThrough(snapshotSequence); } const snapshot = yield* readThreads(); - const refreshedDefaultRefs = new Map>(); + const refreshedDefaultRefs = new Set(); const groups = Map.groupBy( snapshot.threads.filter((thread) => thread.worktreePath !== null), (thread) => path.resolve(thread.worktreePath!), @@ -233,25 +232,12 @@ export const make = Effect.gen(function* () { return; if ((yield* fs.realPath(worktreePath)) !== worktreePath) return; if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return; - // A linked worktree has a .git file. Never remove a main checkout. - if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") return; - const status = yield* git.statusDetailsLocal(worktreePath); - if (!status.isRepo || status.branch !== thread.branch || status.hasWorkingTreeChanges) - return; - const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); - const ignored = yield* git.execute({ - operation: "StorageCleanup.ignoredFiles", - cwd: worktreePath, - args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], - maxOutputBytes: 64 * 1024, - }); - // Ignored files can contain secrets or local datasets. Dependency installs - // are reproducible; every other ignored path prevents automatic removal. + const status = yield* vcs.inspectWorkspaceForCleanup(worktreePath); if ( - ignored.stdoutTruncated || - ignored.stdout - .split("\0") - .some((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)) + !status.isSecondary || + status.refName !== thread.branch || + status.hasWorkingTreeChanges || + status.hasUnpreservedFiles ) return; const old = @@ -260,35 +246,19 @@ export const make = Effect.gen(function* () { storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS; let eligible = deleted || old; if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { - const repositoryCwd = path.resolve(project.workspaceRoot); - const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd); - const branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); - if (branch === null) return; - const defaultRef = `refs/remotes/${remote}/${branch}`; - const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set(); - if (!refreshed.has(defaultRef)) { - yield* git.fetchRemoteTrackingBranch({ - cwd: repositoryCwd, - remoteName: remote, - remoteBranch: branch, - }); - refreshed.add(defaultRef); - refreshedDefaultRefs.set(repositoryCwd, refreshed); - } - const base = yield* git.resolveCommit({ - cwd: worktreePath, - revision: defaultRef, - }); - const ancestor = yield* git.execute({ - operation: "StorageCleanup.integratedBranch", + const base = yield* vcs.workspaceIntegrationBase({ cwd: worktreePath, - args: ["merge-base", "--is-ancestor", head.commitSha, base.commitSha], - allowNonZeroExit: true, + refresh: !refreshedDefaultRefs.has(project.workspaceRoot), }); - if (ancestor.exitCode !== 0) return; + refreshedDefaultRefs.add(project.workspaceRoot); + if ( + base === null || + !(yield* vcs.isRevisionAncestor({ cwd: worktreePath, from: status.revision, to: base })) + ) + return; eligible = settings.worktreeUnchanged; if (!eligible && settings.worktreeOnMerge && thread.branch !== null) { - const pullRequest = yield* gitManager.branchPullRequest( + const pullRequest = yield* vcs.branchPullRequest( { cwd: worktreePath, branch: thread.branch }, { refresh: true }, ); @@ -332,29 +302,13 @@ export const make = Effect.gen(function* () { storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) ) return; - const finalStatus = yield* git.statusDetailsLocal(worktreePath); + const finalStatus = yield* vcs.inspectWorkspaceForCleanup(worktreePath); if ( - !finalStatus.isRepo || - finalStatus.branch !== thread.branch || - finalStatus.hasWorkingTreeChanges - ) - return; - if ( - (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !== - head.commitSha - ) - return; - const finalIgnored = yield* git.execute({ - operation: "StorageCleanup.ignoredFiles", - cwd: worktreePath, - args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], - maxOutputBytes: 64 * 1024, - }); - if ( - finalIgnored.stdoutTruncated || - finalIgnored.stdout - .split("\0") - .some((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)) + !finalStatus.isSecondary || + finalStatus.refName !== thread.branch || + finalStatus.hasWorkingTreeChanges || + finalStatus.hasUnpreservedFiles || + finalStatus.revision !== status.revision ) return; const current = resolveWorktreeCleanup( @@ -368,8 +322,8 @@ export const make = Effect.gen(function* () { ) ) return; - yield* git.removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false }); - yield* gitManager.invalidateStatus(project.workspaceRoot); + yield* vcs.removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false }); + yield* vcs.invalidateStatus(project.workspaceRoot); // Preserve branch and path: ProviderCommandReactor recreates the checkout // from that branch when the thread is resumed. yield* Effect.logInfo("storage cleanup removed worktree", { threadId: thread.id }); @@ -441,6 +395,7 @@ export const make = Effect.gen(function* () { ), ), ); + const initialSweepEnqueued = yield* Deferred.make(); const start = Effect.fn("StorageCleanup.start")(function* () { const unsubscribe = yield* terminals.subscribeMetadata((event) => @@ -462,13 +417,12 @@ export const make = Effect.gen(function* () { const events = yield* engine.subscribeDomainEvents; let lastSettings = yield* settingsService.getSettings.pipe(Effect.orDie); yield* forkParked( - worker - .enqueue(undefined) - .pipe( - Effect.andThen(worker.drain), - Effect.repeat(Schedule.spaced("1 hour")), - Effect.asVoid, - ), + worker.enqueue(undefined).pipe( + Effect.tap(() => Deferred.succeed(initialSweepEnqueued, undefined)), + Effect.andThen(worker.drain), + Effect.repeat(Schedule.spaced("1 hour")), + Effect.asVoid, + ), ); yield* forkParked( Stream.runForEach(changes, (settings) => { @@ -491,7 +445,10 @@ export const make = Effect.gen(function* () { ), ); }); - return { start, drain: worker.drain } satisfies StorageCleanup["Service"]; + return { + start, + drain: Deferred.await(initialSweepEnqueued).pipe(Effect.andThen(worker.drain)), + } satisfies StorageCleanup["Service"]; }); export const layer = Layer.effect(StorageCleanup, make); diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 4df6d0eefc5c..407fde8a7526 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -60,6 +60,7 @@ const makeCaptureStore = Effect.fn("test.makeCaptureStore")(function* ( get: () => Effect.succeed(driver), detect: () => Effect.succeed(handle), resolve: () => Effect.succeed(handle), + invalidate: () => Effect.void, }), ); }); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 9ea1ba402f6e..98d3ac91eb0b 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -38,6 +38,7 @@ import { splitNullSeparatedGitStdoutPaths, } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; +import { chunkPathsForCheckIgnore } from "./VcsPathCodecs.ts"; import * as VcsProcess from "./VcsProcess.ts"; export interface ExecuteGitInput { @@ -394,7 +395,6 @@ export class GitVcsDriver extends Context.Service< const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const CHECKPOINT_RECOVERY_MAX_CANDIDATES = 64; const CHECKPOINT_RECOVERY_TIMEOUT = "5 seconds"; -const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ "-c", @@ -412,36 +412,6 @@ const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { }; }); -function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray): string[][] { - const chunks: string[][] = []; - let chunk: string[] = []; - let chunkBytes = 0; - - for (const relativePath of relativePaths) { - const relativePathBytes = Buffer.byteLength(relativePath) + 1; - if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { - chunks.push(chunk); - chunk = []; - chunkBytes = 0; - } - - chunk.push(relativePath); - chunkBytes += relativePathBytes; - - if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { - chunks.push(chunk); - chunk = []; - chunkBytes = 0; - } - } - - if (chunk.length > 0) { - chunks.push(chunk); - } - - return chunks; -} - function parseGitRemoteVerboseOutput( output: string, ): Map { @@ -669,7 +639,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } const ignoredPaths = new Set(); - const chunks = chunkPathsForGitCheckIgnore(relativePaths); + const chunks = chunkPathsForCheckIgnore(relativePaths); for (const chunk of chunks) { const result = yield* gitCommand( @@ -780,6 +750,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION; + const reportedExclusions: string[] = []; const indexConfig = [ "-c", "core.fsmonitor=false", @@ -989,6 +960,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( !(yield* hasHeadCommit(nestedCwd, nestedRepoEnv)) ) { exclusions.push(`:(exclude,literal)${entry}`); + reportedExclusions.push(`${entry} (embedded repository has no commits)`); } } if (exclusions.length === 0) return yield* error; @@ -1044,6 +1016,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( args: [...durableWrite, "update-ref", input.checkpointRef, commitOid], }); }).pipe(Effect.ensuring(cleanupTempIndex)); + return { exclusions: reportedExclusions }; }), hasCheckpointRef: (input) => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b7335e3f0705..8f0ecf2286f5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -87,7 +87,7 @@ const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ SSH_ASKPASS_REQUIRE: "never", } satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; -const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; +export const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ isRepo: false, hasOriginRemote: false, @@ -197,7 +197,7 @@ function parseNumstatEntries( } // -z preserves tabs/newlines in paths and gives renames two separate path fields. -function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { +export function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { const fields = stdout.split("\0"); const files: ReviewDiffFileStat[] = []; for (let index = 0; index < fields.length; index++) { @@ -238,7 +238,7 @@ function parsePorcelainPath(line: string): string | null { return filePath.length > 0 ? filePath : null; } -function filterBranchesForListQuery( +export function filterBranchesForListQuery( refs: ReadonlyArray, query?: string, ): ReadonlyArray { @@ -250,7 +250,7 @@ function filterBranchesForListQuery( return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery)); } -function paginateBranches(input: { +export function paginateBranches(input: { refs: ReadonlyArray; cursor?: number | undefined; limit?: number | undefined; diff --git a/apps/server/src/vcs/JjAvailability.test.ts b/apps/server/src/vcs/JjAvailability.test.ts new file mode 100644 index 000000000000..4f96ebc3985e --- /dev/null +++ b/apps/server/src/vcs/JjAvailability.test.ts @@ -0,0 +1,149 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { VcsProcessSpawnError } from "@t3tools/contracts"; +import * as JjAvailability from "./JjAvailability.ts"; +import * as VcsProcess from "./VcsProcess.ts"; + +const versionProcess = (stdout: string, calls: { count: number }) => + Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.sync(() => { + calls.count += 1; + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + +const missingBinaryProcess = Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error("spawn jj ENOENT"), + }), + ), +}); + +const probeOnce = (layer: Layer.Layer) => + Effect.gen(function* () { + const availability = yield* JjAvailability.makeJjAvailability; + return yield* availability("/repo"); + }).pipe(Effect.provide(layer)); + +describe("makeJjAvailability", () => { + it.effect("accepts a release build at the supported floor", () => { + const calls = { count: 0 }; + return probeOnce(versionProcess("jj 0.42.0\n", calls)).pipe( + Effect.map((availability) => { + assert.deepStrictEqual(availability, { _tag: "available", version: "0.42.0" }); + }), + ); + }); + + it.effect("ignores the commit suffix of a source build", () => { + const calls = { count: 0 }; + return probeOnce( + versionProcess("jj 0.45.1-7c41cdeb16b6b321c64e789a966b6adf723816a5\n", calls), + ).pipe( + Effect.map((availability) => { + assert.deepStrictEqual(availability, { _tag: "available", version: "0.45.1" }); + }), + ); + }); + + it.effect("rejects a binary below the floor", () => { + const calls = { count: 0 }; + return probeOnce(versionProcess("jj 0.41.9\n", calls)).pipe( + Effect.map((availability) => { + assert.deepStrictEqual(availability, { _tag: "unsupported-version", version: "0.41.9" }); + }), + ); + }); + + it.effect("reports a missing binary instead of failing", () => + probeOnce(missingBinaryProcess).pipe( + Effect.map((availability) => { + assert.deepStrictEqual(availability, { _tag: "missing" }); + }), + ), + ); + + it.effect("probes once no matter how many callers ask", () => { + const calls = { count: 0 }; + + return Effect.gen(function* () { + const availability = yield* JjAvailability.makeJjAvailability; + yield* Effect.all([availability("/repo"), availability("/other"), availability("/repo")], { + concurrency: "unbounded", + }); + + assert.equal(calls.count, 1); + }).pipe(Effect.provide(versionProcess("jj 0.42.0\n", calls))); + }); + + it.effect("reprobes after an unavailable version becomes supported", () => { + let version = "jj 0.41.9\n"; + const calls = { count: 0 }; + const process = Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.sync(() => { + calls.count++; + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: version, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + return Effect.gen(function* () { + const availability = yield* JjAvailability.makeJjAvailability; + assert.deepStrictEqual(yield* availability("/repo"), { + _tag: "unsupported-version", + version: "0.41.9", + }); + version = "jj 0.42.0\n"; + assert.deepStrictEqual(yield* availability("/repo"), { + _tag: "available", + version: "0.42.0", + }); + assert.deepStrictEqual(yield* availability("/other"), { + _tag: "available", + version: "0.42.0", + }); + assert.equal(calls.count, 2); + }).pipe(Effect.provide(process)); + }); +}); + +describe("jjUnsupportedReason", () => { + it("reports a reason for every unusable repository and stays silent for a usable one", () => { + const reasonFor = (input: Parameters[0]) => + JjAvailability.jjUnsupportedReason(input) !== null; + + assert.isTrue(reasonFor({ availability: { _tag: "missing" }, colocated: true })); + assert.isTrue( + reasonFor({ + availability: { _tag: "unsupported-version", version: "0.41.0" }, + colocated: true, + }), + ); + assert.isTrue( + reasonFor({ availability: { _tag: "available", version: "0.42.0" }, colocated: false }), + ); + assert.isFalse( + reasonFor({ availability: { _tag: "available", version: "0.42.0" }, colocated: true }), + ); + }); +}); diff --git a/apps/server/src/vcs/JjAvailability.ts b/apps/server/src/vcs/JjAvailability.ts new file mode 100644 index 000000000000..cfadb00e06f6 --- /dev/null +++ b/apps/server/src/vcs/JjAvailability.ts @@ -0,0 +1,124 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; + +import * as JjProcess from "./JjProcess.ts"; +import * as VcsProcess from "./VcsProcess.ts"; + +/** + * The floor is the version every command, revset and template in the jj lane was verified against. + * Lowering it requires a CI job that runs the jj suite against the new floor binary + * (`.github/actions/setup-jj` takes a `version` input), not a guess about which release first + * shipped `json()`, `stringify()` or `bookmark track --remote=`. + */ +export const JJ_MINIMUM_VERSION = "0.42.0"; + +export type JjAvailability = + | { readonly _tag: "available"; readonly version: string } + | { readonly _tag: "missing" } + | { readonly _tag: "unsupported-version"; readonly version: string }; + +const VERSION_PATTERN = /^jj (\d+)\.(\d+)\.(\d+)/; +const VERSION_PROBE_TIMEOUT_MS = 5_000; +const VERSION_PROBE_MAX_OUTPUT_BYTES = 4_096; + +/** + * Reads `major.minor.patch` out of a `jj --version` banner, ignoring the `-` suffix a source + * build prints. `null` when the output is not a jj version banner at all. + */ +export function parseJjVersion(versionOutput: string): string | null { + const line = versionOutput.split(/\r?\n/g).find((candidate) => candidate.trim().length > 0); + const match = line === undefined ? null : VERSION_PATTERN.exec(line.trim()); + return match ? `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}` : null; +} + +function versionParts(version: string): ReadonlyArray { + return version.split(".").map((part) => Number(part)); +} + +/** Whether a version parsed by {@link parseJjVersion} meets {@link JJ_MINIMUM_VERSION}. */ +export function isJjVersionSupported(version: string): boolean { + const observed = versionParts(version); + const required = versionParts(JJ_MINIMUM_VERSION); + for (let index = 0; index < required.length; index += 1) { + const left = observed[index] ?? 0; + const right = required[index] ?? 0; + if (left !== right) { + return left > right; + } + } + return true; +} + +/** + * Caches a supported `jj --version` result. Takes a cwd because jj refuses to run + * without a valid existing working directory, even for `--version`; callers always have a resolved + * workspace root in hand. + */ +export const makeJjAvailability = Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const cached = yield* Ref.make(Option.none()); + const probeLock = yield* Semaphore.make(1); + + const probe = Effect.fn("JjAvailability.probe")(function* (cwd: string) { + const result = yield* JjProcess.jjCommand(process, "JjAvailability.probe", cwd, ["--version"], { + allowNonZeroExit: true, + timeoutMs: VERSION_PROBE_TIMEOUT_MS, + maxOutputBytes: VERSION_PROBE_MAX_OUTPUT_BYTES, + }).pipe(Effect.orElseSucceed(() => null)); + + if (result === null || result.exitCode !== 0) { + return { _tag: "missing" } as const; + } + + const version = parseJjVersion(result.stdout); + if (version === null) { + return { _tag: "missing" } as const; + } + + return isJjVersionSupported(version) + ? ({ _tag: "available", version } as const) + : ({ _tag: "unsupported-version", version } as const); + }); + + return Effect.fn("JjAvailability.availability")(function* ( + cwd: string, + ): Effect.fn.Return { + const known = yield* Ref.get(cached); + if (Option.isSome(known)) { + return known.value; + } + + return yield* probeLock.withPermits(1)( + Effect.gen(function* () { + const raced = yield* Ref.get(cached); + if (Option.isSome(raced)) { + return raced.value; + } + const availability = yield* probe(cwd); + if (availability._tag === "available") { + yield* Ref.set(cached, Option.some(availability)); + } + return availability; + }), + ); + }); +}); + +/** The user-facing string that lands in `VcsStatusLocalResult.vcs.unsupportedReason`. */ +export function jjUnsupportedReason(input: { + readonly availability: JjAvailability; + readonly colocated: boolean; +}): string | null { + if (input.availability._tag === "missing") { + return "Jujutsu is not installed on this server. Install jj, then rescan in Settings → Source Control."; + } + if (input.availability._tag === "unsupported-version") { + return `Jujutsu ${input.availability.version} is older than the supported minimum ${JJ_MINIMUM_VERSION}. Upgrade jj to use this repository in T3 Code.`; + } + if (!input.colocated) { + return "T3 Code supports colocated Jujutsu repositories. Run `jj git init --colocate` in this repository, or open its Git checkout instead."; + } + return null; +} diff --git a/apps/server/src/vcs/JjCheckpoints.test.ts b/apps/server/src/vcs/JjCheckpoints.test.ts new file mode 100644 index 000000000000..a99d7c772c74 --- /dev/null +++ b/apps/server/src/vcs/JjCheckpoints.test.ts @@ -0,0 +1,533 @@ +import { assert, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; + +import { parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; +import { checkpointRefForThreadTurn } from "../checkpointing/Utils.ts"; +import type * as JjVcsDriver from "./JjVcsDriver.ts"; +import type * as VcsDriver from "./VcsDriver.ts"; +import { makeJjCheckpointOps } from "./JjCheckpoints.ts"; +import type { VcsProcessInput } from "./VcsProcess.ts"; +import { + changeId, + commitId, + describeJj, + jjOut, + read, + runGit, + runJj, + withJjRepo, + write, +} from "./testing/JjTestSupport.ts"; + +const threadId = ThreadId.make("thread-jj-checkpoints"); +const baselineRef = checkpointRefForThreadTurn(threadId, 0); +const firstTurnRef = checkpointRefForThreadTurn(threadId, 1); +const missingRef = checkpointRefForThreadTurn(threadId, 99); + +function requireCheckpoints(driver: JjVcsDriver.JjVcsDriverShape): VcsDriver.VcsCheckpointOps { + const checkpoints = driver.checkpoints; + if (!checkpoints) { + assert.fail("The Jujutsu driver exposes no checkpoint operations."); + } + return checkpoints; +} + +interface RepoContext { + readonly checkpoints: VcsDriver.VcsCheckpointOps; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + /** Scoped temp directory holding the repository, so sibling workspaces cannot collide. */ + readonly base: string; + readonly root: string; +} + +const withRepo = ( + use: (context: RepoContext) => Effect.Effect, +) => + withJjRepo({ prefix: "t3-jj-checkpoints-" }, (repo) => + use({ ...repo, checkpoints: requireCheckpoints(repo.driver) }), + ); + +describeJj("JjCheckpoints capture", () => { + it.effect("flushes the checkpoint ref when publishing into the colocated Git store", () => + withJjRepo({ prefix: "t3-jj-durable-checkpoint-", seed: true }, (context) => + Effect.gen(function* () { + const commit = yield* commitId(context.root, "@"); + const commands: VcsProcessInput[] = []; + const checkpoints = makeJjCheckpointOps({ + fileSystem: context.fileSystem, + path: context.path, + process: { + run: (input) => { + commands.push(input); + return context.process.run(input); + }, + }, + ensureGitDir: () => Effect.succeed(context.path.join(context.root, ".git")), + currentChange: () => + Effect.succeed({ + commitId: commit, + parentCommitIds: [], + description: "", + empty: true, + conflict: false, + localBookmarks: [], + fileStats: [], + }), + }); + yield* checkpoints.captureCheckpoint({ cwd: context.root, checkpointRef: baselineRef }); + const publish = commands.find((command) => command.args.includes("update-ref")); + assert.isTrue(publish?.args.includes("core.fsync=objects,reference")); + assert.isTrue(publish?.args.includes("core.fsyncMethod=fsync")); + assert.equal((yield* runGit(context.root, ["rev-parse", baselineRef])).trim(), commit); + }), + ), + ); + + it.effect("pins the working-copy commit without moving @, a bookmark, or a head", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "v1\n"); + yield* runJj(context.root, ["commit", "-m", "base"]); + yield* runJj(context.root, ["bookmark", "create", "feature", "-r", "@-"]); + yield* write(context, "a.txt", "v2\n"); + + const changeBefore = yield* changeId(context.root, "@"); + const bookmarksBefore = yield* jjOut(context.root, ["bookmark", "list", "--all-remotes"]); + + yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: baselineRef, + }); + + assert.isTrue( + yield* context.checkpoints.hasCheckpointRef({ + cwd: context.root, + checkpointRef: baselineRef, + }), + ); + assert.equal(yield* changeId(context.root, "@"), changeBefore); + assert.equal( + yield* jjOut(context.root, ["bookmark", "list", "--all-remotes"]), + bookmarksBefore, + ); + assert.equal( + yield* commitId(context.root, "@"), + yield* runGit(context.root, ["rev-parse", baselineRef]).pipe( + Effect.map((stdout) => stdout.trim()), + ), + ); + }), + ), + ); + + it.effect("writes the ref into the main workspace's Git store from a secondary workspace", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "v1\n"); + yield* runJj(context.root, ["commit", "-m", "base"]); + const secondary = context.path.join(context.base, "ws2"); + yield* runJj(context.root, ["workspace", "add", secondary]); + yield* context.fileSystem.writeFileString( + context.path.join(secondary, "b.txt"), + "secondary\n", + ); + + yield* context.checkpoints.captureCheckpoint({ + cwd: secondary, + checkpointRef: firstTurnRef, + }); + + assert.equal( + yield* runGit(context.root, ["rev-parse", firstTurnRef]).pipe( + Effect.map((stdout) => stdout.trim()), + ), + yield* commitId(secondary, "@"), + ); + }), + ), + ); + + it.effect("leaves a file above jj's snapshot limit out of the checkpoint and on disk", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "small.txt", "v1\n"); + yield* write(context, "big.txt", "x".repeat(10 * 1024 * 1024 + 1)); + // The exclusion must remain visible even after another command has already snapshotted. + yield* runJj(context.root, ["status"]); + + const capture = yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: baselineRef, + }); + assert.include(capture.exclusions.join("\n"), "big.txt"); + + const trackedPaths = yield* runGit(context.root, [ + "ls-tree", + "--name-only", + "-r", + baselineRef, + ]); + assert.notInclude(trackedPaths, "big.txt"); + assert.include(trackedPaths, "small.txt"); + + yield* write(context, "small.txt", "v2\n"); + assert.isTrue( + yield* context.checkpoints.restoreCheckpoint({ + cwd: context.root, + checkpointRef: baselineRef, + }), + ); + + assert.equal(yield* read(context, "small.txt"), "v1\n"); + assert.isTrue(yield* context.fileSystem.exists(context.path.join(context.root, "big.txt"))); + }), + ), + ); + + it.effect( + "captures tracked files that grow beyond the new-file limit without reporting ignored files", + () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, ".gitignore", "ignored.txt\n"); + yield* write(context, "tracked.txt", "small\n"); + yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: baselineRef, + }); + const contents = "x".repeat(10 * 1024 * 1024 + 1); + yield* write(context, "tracked.txt", contents); + yield* write(context, "ignored.txt", "ordinary ignored data\n"); + const captured = yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: firstTurnRef, + }); + assert.deepStrictEqual(captured.exclusions, []); + yield* write(context, "tracked.txt", "changed\n"); + yield* context.checkpoints.restoreCheckpoint({ + cwd: context.root, + checkpointRef: firstTurnRef, + }); + assert.equal(yield* read(context, "tracked.txt"), contents); + assert.equal(yield* read(context, "ignored.txt"), "ordinary ignored data\n"); + }), + ), + ); +}); + +describeJj("JjCheckpoints restore", () => { + it.effect("restores contents while preserving the change, description, and every bookmark", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "v1\n"); + yield* runJj(context.root, ["commit", "-m", "base"]); + yield* runJj(context.root, ["bookmark", "create", "feature", "-r", "@-"]); + yield* runJj(context.root, ["describe", "-m", "work in progress"]); + yield* write(context, "a.txt", "v2\n"); + yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: firstTurnRef, + }); + + const secondary = context.path.join(context.base, "ws2"); + yield* runJj(context.root, ["workspace", "add", secondary]); + const secondaryBefore = yield* commitId(secondary, "@"); + const changeBefore = yield* changeId(context.root, "@"); + const bookmarksBefore = yield* jjOut(context.root, ["bookmark", "list", "--all-remotes"]); + + yield* write(context, "a.txt", "v3\n"); + assert.isTrue( + yield* context.checkpoints.restoreCheckpoint({ + cwd: context.root, + checkpointRef: firstTurnRef, + }), + ); + + assert.equal(yield* read(context, "a.txt"), "v2\n"); + assert.equal(yield* changeId(context.root, "@"), changeBefore); + assert.equal( + yield* jjOut(context.root, ["log", "-r", "@", "--no-graph", "-T", "description"]), + "work in progress", + ); + assert.equal( + yield* jjOut(context.root, ["bookmark", "list", "--all-remotes"]), + bookmarksBefore, + ); + assert.equal(yield* commitId(secondary, "@"), secondaryBefore); + }), + ), + ); + + it.effect("falls back to @-, not @, when the checkpoint ref is missing", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "v1\n"); + yield* runJj(context.root, ["commit", "-m", "base"]); + yield* write(context, "a.txt", "v2\n"); + + assert.isTrue( + yield* context.checkpoints.restoreCheckpoint({ + cwd: context.root, + checkpointRef: missingRef, + fallbackToHead: true, + }), + ); + + assert.equal(yield* read(context, "a.txt"), "v1\n"); + }), + ), + ); + + it.effect("returns false for a missing ref, with and without the head fallback", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "v1\n"); + yield* write(context, "b.txt", "v1\n"); + + assert.isFalse( + yield* context.checkpoints.restoreCheckpoint({ + cwd: context.root, + checkpointRef: missingRef, + }), + ); + // In a repository with no commits the fallback has no `@-` to resolve either. + assert.isFalse( + yield* context.checkpoints.restoreCheckpoint({ + cwd: context.root, + checkpointRef: missingRef, + fallbackToHead: true, + }), + ); + + const entries = yield* context.fileSystem.readDirectory(context.root); + assert.includeMembers([...entries], ["a.txt", "b.txt"]); + assert.equal(yield* read(context, "a.txt"), "v1\n"); + }), + ), + ); + + it.effect("recovers a checkpoint jj's own index has lost, leaving no bookmark behind", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "v1\n"); + yield* runJj(context.root, ["commit", "-m", "base"]); + yield* write(context, "a.txt", "v2\n"); + yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: firstTurnRef, + }); + + // A commit git still holds but jj cannot name is what an abandoned operation log or an + // expired `jj util gc` leaves behind; a fresh object with the checkpoint's tree is the + // deterministic stand-in. + const captured = yield* commitId(context.root, "@"); + const orphan = yield* runGit(context.root, [ + "-c", + "user.name=T3 Code Test", + "-c", + "user.email=t3code-test@example.com", + "commit-tree", + `${captured}^{tree}`, + "-p", + `${captured}^`, + "-m", + "checkpoint", + ]).pipe(Effect.map((stdout) => stdout.trim())); + yield* runGit(context.root, ["update-ref", firstTurnRef, orphan]); + yield* write(context, "a.txt", "v3\n"); + assert.isTrue( + yield* Effect.isFailure( + runJj(context.root, ["log", "-r", orphan, "--no-graph", "-T", "commit_id"]), + ), + ); + + assert.isTrue( + yield* context.checkpoints.restoreCheckpoint({ + cwd: context.root, + checkpointRef: firstTurnRef, + }), + ); + + assert.equal(yield* read(context, "a.txt"), "v2\n"); + assert.notInclude( + yield* jjOut(context.root, ["bookmark", "list", "--all-remotes"]), + "t3-restore-", + ); + assert.notInclude( + yield* runGit(context.root, ["for-each-ref", "refs/heads"]), + "t3-restore-", + ); + }), + ), + ); +}); + +describeJj("JjCheckpoints diff", () => { + it.effect("renders two turns as numstat and as a patch the client parsers read", () => + withRepo( + Effect.fnUntraced(function* (context) { + const renamedBody = Array.from({ length: 20 }, (_, index) => `line ${index}\n`).join(""); + yield* write(context, "rename-old.txt", renamedBody); + yield* write(context, "kept.txt", "v1\n"); + yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: baselineRef, + }); + + yield* context.fileSystem.rename( + context.path.join(context.root, "rename-old.txt"), + context.path.join(context.root, "rename-new.txt"), + ); + yield* write(context, "kept.txt", "v2\n"); + yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: firstTurnRef, + }); + + const numstat = yield* context.checkpoints.diffCheckpoints({ + cwd: context.root, + fromCheckpointRef: baselineRef, + toCheckpointRef: firstTurnRef, + ignoreWhitespace: false, + format: "numstat", + }); + + assert.deepStrictEqual(parseTurnDiffFilesFromNumstat(numstat), [ + { path: "kept.txt", additions: 1, deletions: 1 }, + { path: "rename-new.txt", additions: 0, deletions: 0 }, + ]); + + const patch = yield* context.checkpoints.diffCheckpoints({ + cwd: context.root, + fromCheckpointRef: baselineRef, + toCheckpointRef: firstTurnRef, + ignoreWhitespace: false, + }); + + assert.isTrue(patch.startsWith("diff --git a/")); + assert.include(patch, "--- a/kept.txt"); + assert.include(patch, "+++ b/kept.txt"); + }), + ), + ); + + it.effect("diffs against the empty tree in a repository with no commits", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "v1\n"); + yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: firstTurnRef, + }); + + const numstat = yield* context.checkpoints.diffCheckpoints({ + cwd: context.root, + fromCheckpointRef: baselineRef, + toCheckpointRef: firstTurnRef, + fallbackFromToHead: true, + ignoreWhitespace: false, + format: "numstat", + }); + + assert.deepStrictEqual(parseTurnDiffFilesFromNumstat(numstat), [ + { path: "a.txt", additions: 1, deletions: 0 }, + ]); + }), + ), + ); + + it.effect("fails without a fallback when an endpoint cannot be resolved", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "v1\n"); + yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: firstTurnRef, + }); + + const error = yield* Effect.flip( + context.checkpoints.diffCheckpoints({ + cwd: context.root, + fromCheckpointRef: baselineRef, + toCheckpointRef: firstTurnRef, + ignoreWhitespace: false, + format: "numstat", + }), + ); + + assert.equal(error._tag, "VcsProcessExitError"); + }), + ), + ); + + it.effect("excludes the conflict scaffolding of a conflicted endpoint", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "base\n"); + yield* runJj(context.root, ["commit", "-m", "base"]); + const baseCommit = yield* commitId(context.root, "@-"); + yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: baselineRef, + }); + yield* write(context, "a.txt", "one\n"); + yield* runJj(context.root, ["commit", "-m", "one"]); + const sideOne = yield* commitId(context.root, "@-"); + yield* runJj(context.root, ["new", baseCommit]); + yield* write(context, "a.txt", "two\n"); + yield* runJj(context.root, ["commit", "-m", "two"]); + const sideTwo = yield* commitId(context.root, "@-"); + yield* runJj(context.root, ["new", sideOne, sideTwo]); + + yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: firstTurnRef, + }); + + const numstat = yield* context.checkpoints.diffCheckpoints({ + cwd: context.root, + fromCheckpointRef: baselineRef, + toCheckpointRef: firstTurnRef, + ignoreWhitespace: false, + format: "numstat", + }); + + assert.notInclude(numstat, ".jjconflict-"); + assert.notInclude(numstat, "JJ-CONFLICT-README"); + assert.deepStrictEqual(parseTurnDiffFilesFromNumstat(numstat), [ + { path: "a.txt", additions: 1, deletions: 1 }, + ]); + }), + ), + ); +}); + +describeJj("JjCheckpoints delete", () => { + it.effect("removes captured refs and tolerates missing ones", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "v1\n"); + yield* context.checkpoints.captureCheckpoint({ + cwd: context.root, + checkpointRef: baselineRef, + }); + + yield* context.checkpoints.deleteCheckpointRefs({ + cwd: context.root, + checkpointRefs: [baselineRef, missingRef], + }); + + assert.isFalse( + yield* context.checkpoints.hasCheckpointRef({ + cwd: context.root, + checkpointRef: baselineRef, + }), + ); + }), + ), + ); +}); diff --git a/apps/server/src/vcs/JjCheckpoints.ts b/apps/server/src/vcs/JjCheckpoints.ts new file mode 100644 index 000000000000..e28dbe459263 --- /dev/null +++ b/apps/server/src/vcs/JjCheckpoints.ts @@ -0,0 +1,350 @@ +import * as NodeCrypto from "node:crypto"; + +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; + +import { type VcsError, VcsProcessExitError } from "@t3tools/contracts"; +import { PATCH_RENDER_PREFIX_ARGS } from "./GitVcsDriverCore.ts"; +import { colocatedGitCommand, jjCommand } from "./JjProcess.ts"; +import type { JjChange } from "./JjVcsDriver.ts"; +import type * as VcsDriver from "./VcsDriver.ts"; +import type * as VcsProcess from "./VcsProcess.ts"; +import { inspectWorkspaceFiles } from "./WorkspaceFileSafety.ts"; +import { GitCommandError } from "@t3tools/contracts"; + +export interface JjCheckpointDeps { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly process: VcsProcess.VcsProcess["Service"]; + /** Fails unless jj can operate here; yields the colocated git store. */ + readonly ensureGitDir: (operation: string, cwd: string) => Effect.Effect; + readonly currentChange: (cwd: string) => Effect.Effect; +} + +/** + * A conflicted jj commit materialises into git as `.jjconflict-base-N/`, `.jjconflict-side-N/` + * trees plus `JJ-CONFLICT-README`. Excluding them costs nothing in a clean repo (jj reserves the + * names) and keeps a conflicted turn from rendering files that do not exist in the user's tree. + * Note the limitation this cannot fix: the same tree also holds the conflicted path itself with + * ONE arbitrary side's content, so a conflicted turn's diff shows that side rather than the + * markers on disk. + * + * The exclusion carries no `glob` magic on purpose: with it, `*` stops matching `/` and the + * `.jjconflict-base-0/f.txt` rows survive the filter. + */ +export const JJ_CONFLICT_PATHSPECS = [ + "--", + ".", + ":(exclude).jjconflict-*", + ":(exclude)JJ-CONFLICT-README", +] as const; + +/** git's empty tree. The diff base when `@` has no real parent. */ +export const EMPTY_TREE_OID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + +const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; +const CHECKPOINT_REF_UNAVAILABLE_DETAIL = "Checkpoint ref is unavailable for diff operation."; +const MISSING_REVISION_PATTERN = /Revision .* doesn't exist/i; + +export const makeJjCheckpointOps = (deps: JjCheckpointDeps): VcsDriver.VcsCheckpointOps => { + const resolveRefCommit = (gitDir: string, cwd: string, checkpointRef: string) => + colocatedGitCommand( + deps.process, + "JjVcsDriver.checkpoints.resolveCheckpointCommit", + { gitDir, cwd }, + ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], + { allowNonZeroExit: true }, + ).pipe( + Effect.map((result) => { + if (result.exitCode !== 0) { + return null; + } + const commitId = result.stdout.trim(); + return commitId.length > 0 ? commitId : null; + }), + ); + + const restoreFrom = (operation: string, cwd: string, commitId: string) => + jjCommand(deps.process, operation, cwd, ["restore", "--from", commitId], { + allowNonZeroExit: true, + }); + + /** + * Only reachable once `jj op abandon` or `jj util gc` dropped the commit from jj's index; the + * object itself survives in git because the checkpoint ref pins it. + */ + const restoreThroughGitObject = Effect.fn("JjVcsDriver.checkpoints.restoreThroughGitObject")( + function* ( + operation: string, + gitDir: string, + cwd: string, + commitId: string, + ): Effect.fn.Return { + const tempBookmark = `t3-restore-${NodeCrypto.randomUUID()}`; + const tempRef = `refs/heads/${tempBookmark}`; + + yield* Effect.gen(function* () { + yield* colocatedGitCommand(deps.process, operation, { gitDir, cwd }, [ + "update-ref", + tempRef, + commitId, + ]); + yield* jjCommand(deps.process, operation, cwd, ["git", "import"]); + + const retried = yield* restoreFrom(operation, cwd, commitId); + if (retried.exitCode !== 0) { + return yield* Effect.fail( + new VcsProcessExitError({ + operation, + command: "jj restore", + cwd, + exitCode: retried.exitCode, + detail: retried.stderr.trim() || "jj restore failed.", + }), + ); + } + }).pipe( + Effect.ensuring( + Effect.all( + [ + jjCommand(deps.process, operation, cwd, ["bookmark", "forget", tempBookmark], { + allowNonZeroExit: true, + }), + colocatedGitCommand( + deps.process, + operation, + { gitDir, cwd }, + ["update-ref", "-d", tempRef], + { allowNonZeroExit: true }, + ), + ], + { discard: true }, + ).pipe(Effect.ignore), + ), + ); + }, + ); + + return { + captureCheckpoint: Effect.fn("JjVcsDriver.checkpoints.captureCheckpoint")(function* (input) { + const operation = "JjVcsDriver.checkpoints.captureCheckpoint"; + const gitDir = yield* deps.ensureGitDir(operation, input.cwd); + + // Reading `@` without `--ignore-working-copy` is the snapshot: jj writes a new commit id + // under the same change id, moving neither `@` nor any bookmark. + const change = yield* deps.currentChange(input.cwd); + if (change.conflict) { + yield* Effect.logWarning("jj checkpoint captured a conflicted working copy", { + cwd: input.cwd, + checkpointRef: input.checkpointRef, + }); + } + + yield* colocatedGitCommand(deps.process, operation, { gitDir, cwd: input.cwd }, [ + "-c", + "core.fsync=objects,reference", + "-c", + "core.fsyncMethod=fsync", + "update-ref", + input.checkpointRef, + change.commitId, + ]); + const inspection = yield* inspectWorkspaceFiles({ + includeIgnored: false, + cwd: input.cwd, + repositoryCwd: input.cwd, + revision: change.commitId, + fileSystem: deps.fileSystem, + path: deps.path, + execute: (request) => + colocatedGitCommand( + deps.process, + request.operation, + { gitDir, cwd: request.cwd }, + request.args, + { + ...(request.env ? { env: request.env } : {}), + ...(request.maxOutputBytes !== undefined + ? { maxOutputBytes: request.maxOutputBytes } + : {}), + }, + ).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: request.operation, + command: "git", + cwd: request.cwd, + detail: "Could not inspect checkpoint exclusions.", + cause, + }), + ), + ), + }).pipe( + Effect.mapError( + (cause) => + new VcsProcessExitError({ + operation, + command: "git ls-files", + cwd: input.cwd, + exitCode: 1, + detail: `Checkpoint was captured but its exclusions could not be inspected: ${cause.message}`, + }), + ), + ); + return { + exclusions: [ + ...(inspection.exclusions.length > 0 + ? [ + `Files outside this checkpoint: ${inspection.exclusions.slice(0, 50).join(", ")}${inspection.exclusions.length > 50 ? " (more omitted)" : ""}`, + ] + : []), + ...(inspection.truncated + ? [ + "Checkpoint exclusion listing was truncated; additional files may be outside this checkpoint.", + ] + : []), + ], + }; + }), + + hasCheckpointRef: Effect.fn("JjVcsDriver.checkpoints.hasCheckpointRef")(function* (input) { + const gitDir = yield* deps.ensureGitDir( + "JjVcsDriver.checkpoints.hasCheckpointRef", + input.cwd, + ); + return (yield* resolveRefCommit(gitDir, input.cwd, input.checkpointRef)) !== null; + }), + + restoreCheckpoint: Effect.fn("JjVcsDriver.checkpoints.restoreCheckpoint")(function* (input) { + const operation = "JjVcsDriver.checkpoints.restoreCheckpoint"; + const gitDir = yield* deps.ensureGitDir(operation, input.cwd); + + const captured = yield* resolveRefCommit(gitDir, input.cwd, input.checkpointRef); + // `@-`, not `@`: `jj restore --from <@'s own commit id>` changes nothing and reports success. + // It is absent, never the all-zeros root commit, in a repository with no commits. + const commitId = + captured ?? + (input.fallbackToHead === true + ? ((yield* deps.currentChange(input.cwd)).parentCommitIds[0] ?? null) + : null); + + if (commitId === null) { + return false; + } + + const restored = yield* restoreFrom(operation, input.cwd, commitId); + if (restored.exitCode === 0) { + return true; + } + + if (!MISSING_REVISION_PATTERN.test(restored.stderr)) { + return yield* Effect.fail( + new VcsProcessExitError({ + operation, + command: "jj restore", + cwd: input.cwd, + exitCode: restored.exitCode, + detail: restored.stderr.trim() || "jj restore failed.", + }), + ); + } + + yield* restoreThroughGitObject(operation, gitDir, input.cwd, commitId); + return true; + }), + + diffCheckpoints: Effect.fn("JjVcsDriver.checkpoints.diffCheckpoints")(function* (input) { + const operation = "JjVcsDriver.checkpoints.diffCheckpoints"; + yield* Effect.annotateCurrentSpan({ + "checkpoint.cwd": input.cwd, + "checkpoint.from_ref": input.fromCheckpointRef, + "checkpoint.to_ref": input.toCheckpointRef, + "checkpoint.ignore_whitespace": input.ignoreWhitespace, + "checkpoint.format": input.format ?? "patch", + "checkpoint.fallback_from_to_head": input.fallbackFromToHead, + }); + const gitDir = yield* deps.ensureGitDir(operation, input.cwd); + + const [capturedFrom, toOid] = yield* Effect.all([ + resolveRefCommit(gitDir, input.cwd, input.fromCheckpointRef), + resolveRefCommit(gitDir, input.cwd, input.toCheckpointRef), + ]); + const fromOid = + capturedFrom ?? + (input.fallbackFromToHead === true + ? ((yield* deps.currentChange(input.cwd)).parentCommitIds[0] ?? EMPTY_TREE_OID) + : null); + + if (fromOid === null || toOid === null) { + return yield* Effect.fail( + new VcsProcessExitError({ + operation, + command: "git diff", + cwd: input.cwd, + exitCode: 1, + detail: CHECKPOINT_REF_UNAVAILABLE_DETAIL, + }), + ); + } + + const result = yield* colocatedGitCommand( + deps.process, + operation, + { gitDir, cwd: input.cwd }, + [ + "diff", + ...(input.format === "numstat" ? ["--numstat", "-z"] : ["--patch"]), + "--no-color", + "--no-ext-diff", + "--no-textconv", + ...PATCH_RENDER_PREFIX_ARGS, + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + fromOid, + toOid, + ...JJ_CONFLICT_PATHSPECS, + ], + { + allowNonZeroExit: true, + maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + outputMode: input.format === "numstat" ? "error" : "truncate", + }, + ); + + if (result.exitCode !== 0) { + return yield* Effect.fail( + new VcsProcessExitError({ + operation, + command: "git diff", + cwd: input.cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || CHECKPOINT_REF_UNAVAILABLE_DETAIL, + }), + ); + } + + return result.stdout; + }), + + deleteCheckpointRefs: Effect.fn("JjVcsDriver.checkpoints.deleteCheckpointRefs")( + function* (input) { + const operation = "JjVcsDriver.checkpoints.deleteCheckpointRefs"; + const gitDir = yield* deps.ensureGitDir(operation, input.cwd); + + yield* Effect.forEach( + input.checkpointRefs, + (checkpointRef) => + colocatedGitCommand( + deps.process, + operation, + { gitDir, cwd: input.cwd }, + ["update-ref", "-d", checkpointRef], + { allowNonZeroExit: true }, + ), + { discard: true }, + ); + }, + ), + }; +}; diff --git a/apps/server/src/vcs/JjProcess.test.ts b/apps/server/src/vcs/JjProcess.test.ts new file mode 100644 index 000000000000..3c5a5bbdcda5 --- /dev/null +++ b/apps/server/src/vcs/JjProcess.test.ts @@ -0,0 +1,222 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as JjProcess from "./JjProcess.ts"; +import * as VcsProcess from "./VcsProcess.ts"; + +const GLOBAL_ARGS = [ + "--no-pager", + "--color=never", + "--quiet", + "--config", + "git.abandon-unreachable-commits=false", + "--config", + `snapshot.max-new-file-size=${JjProcess.SNAPSHOT_MAX_NEW_FILE_SIZE_BYTES}`, +]; + +const output = ( + overrides: Partial = {}, +): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + ...overrides, +}); + +const recordingProcess = ( + calls: VcsProcess.VcsProcessInput[], + reply: (input: VcsProcess.VcsProcessInput, callIndex: number) => VcsProcess.VcsProcessOutput, +) => + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + const callIndex = calls.length; + calls.push(input); + return reply(input, callIndex); + }), + }); + +describe("jjCommand", () => { + it.effect("puts the abandon guard in the prefix of every invocation", () => { + const calls: VcsProcess.VcsProcessInput[] = []; + + return Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + yield* JjProcess.jjCommand(process, "test.read", "/repo", ["log", "-r", "@"], { + ignoreWorkingCopy: true, + }); + yield* JjProcess.jjCommand(process, "test.write", "/repo", ["git", "fetch"]); + + assert.equal(calls.length, 2); + for (const call of calls) { + assert.equal(call.command, "jj"); + assert.deepStrictEqual(call.args.slice(0, GLOBAL_ARGS.length), GLOBAL_ARGS); + assert.equal(call.cwd, "/repo"); + assert.equal(call.spawnCwd, undefined); + } + assert.deepStrictEqual(calls[0]?.args.slice(GLOBAL_ARGS.length), [ + "--ignore-working-copy", + "log", + "-r", + "@", + ]); + assert.deepStrictEqual(calls[1]?.args.slice(GLOBAL_ARGS.length), ["git", "fetch"]); + }).pipe(Effect.provide(recordingProcess(calls, () => output()))); + }); + + it.effect("places per-call config flags before the command arguments", () => { + const calls: VcsProcess.VcsProcessInput[] = []; + + return Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + yield* JjProcess.jjCommand(process, "test.config", "/repo", ["status"], { + config: [["ui.editor", "false"]], + ignoreWorkingCopy: true, + }); + + assert.deepStrictEqual(calls[0]?.args, [ + ...GLOBAL_ARGS, + "--config", + "ui.editor=false", + "--ignore-working-copy", + "status", + ]); + }).pipe(Effect.provide(recordingProcess(calls, () => output()))); + }); + + it.effect("updates a stale workspace and retries the command exactly once", () => { + const calls: VcsProcess.VcsProcessInput[] = []; + + return Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* JjProcess.jjCommand(process, "test.stale", "/repo", ["status"]); + + assert.equal(result.stdout, "recovered"); + assert.deepStrictEqual( + calls.map((call) => call.args.slice(GLOBAL_ARGS.length).join(" ")), + ["status", "workspace update-stale", "status"], + ); + }).pipe( + Effect.provide( + recordingProcess(calls, (_input, callIndex) => + callIndex === 0 + ? output({ + exitCode: ChildProcessSpawner.ExitCode(1), + stderr: "Error: The working copy is stale (not updated since operation abc).", + }) + : output({ stdout: callIndex === 2 ? "recovered" : "" }), + ), + ), + ); + }); + + it.effect("propagates a second stale failure instead of retrying again", () => { + const calls: VcsProcess.VcsProcessInput[] = []; + + return Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const error = yield* JjProcess.jjCommand(process, "test.stale", "/repo", ["status"]).pipe( + Effect.flip, + ); + + assert.equal(error._tag, "VcsProcessExitError"); + assert.equal(calls.length, 3); + }).pipe( + Effect.provide( + recordingProcess(calls, () => + output({ + exitCode: ChildProcessSpawner.ExitCode(1), + stderr: "Error: The working copy is stale.", + }), + ), + ), + ); + }); + + it.effect("classifies a missing jj repository as not-found", () => { + const calls: VcsProcess.VcsProcessInput[] = []; + + return Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const error = yield* JjProcess.jjCommand(process, "test.missing", "/repo", ["status"]).pipe( + Effect.flip, + ); + + assert.equal(error._tag, "VcsProcessExitError"); + assert.equal(error._tag === "VcsProcessExitError" ? error.failureKind : null, "not-found"); + }).pipe( + Effect.provide( + recordingProcess(calls, () => + output({ + exitCode: ChildProcessSpawner.ExitCode(1), + stderr: 'Error: There is no jj repo in "/repo"', + }), + ), + ), + ); + }); + + it.effect("logs the large-file refusal banner without failing the command", () => { + const calls: VcsProcess.VcsProcessInput[] = []; + const messages: string[] = []; + const logger = Logger.make(({ message }) => { + messages.push(String(message)); + }); + + return Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* JjProcess.jjCommand(process, "test.banner", "/repo", ["status"]); + + assert.equal(result.exitCode, 0); + assert.equal( + messages.filter((message) => message.includes("refused to snapshot some files")).length, + 1, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + recordingProcess(calls, () => + output({ + stderr: + "Warning: Refused to snapshot some files:\n big.bin: 3.0MiB; the maximum size allowed is 1.0MiB\n", + }), + ), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); +}); + +describe("colocatedGitCommand", () => { + it.effect("always passes an explicit git directory and spawns in the given cwd", () => { + const calls: VcsProcess.VcsProcessInput[] = []; + + return Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + yield* JjProcess.colocatedGitCommand( + process, + "test.git", + { gitDir: "/repo/.git", workTree: "/ws", cwd: "/ws" }, + ["check-ignore", "--stdin"], + ); + + assert.equal(calls[0]?.command, "git"); + assert.deepStrictEqual(calls[0]?.args, [ + "--git-dir", + "/repo/.git", + "--work-tree", + "/ws", + "check-ignore", + "--stdin", + ]); + assert.equal(calls[0]?.cwd, "/ws"); + assert.equal(calls[0]?.spawnCwd, undefined); + }).pipe(Effect.provide(recordingProcess(calls, () => output()))); + }); +}); diff --git a/apps/server/src/vcs/JjProcess.ts b/apps/server/src/vcs/JjProcess.ts new file mode 100644 index 000000000000..9a9997905e6f --- /dev/null +++ b/apps/server/src/vcs/JjProcess.ts @@ -0,0 +1,183 @@ +import * as Effect from "effect/Effect"; + +import { type VcsError, VcsProcessExitError } from "@t3tools/contracts"; +import * as VcsProcess from "./VcsProcess.ts"; + +export interface JjCommandOptions { + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly outputMode?: VcsProcess.VcsProcessInput["outputMode"]; + readonly appendTruncationMarker?: boolean; + /** Pure reads that must not snapshot the working copy. Adds `--ignore-working-copy`. */ + readonly ignoreWorkingCopy?: boolean; + /** Extra `--config KEY=VALUE` pairs, applied to this invocation only. */ + readonly config?: ReadonlyArray; +} + +const STALE_WORKSPACE_MARKERS = ["working copy is stale", "concurrent modification detected"]; +const LARGE_FILE_BANNER = "refused to snapshot some files"; +const UPDATE_STALE_TIMEOUT_MS = 30_000; +const LOGGED_STDERR_MAX_CHARS = 2_000; +export const SNAPSHOT_MAX_NEW_FILE_SIZE_BYTES = 10 * 1024 * 1024; + +/** + * A colocated repository imports git refs on every jj command that touches the working copy, so + * `git.abandon-unreachable-commits` (default true) lets an ordinary status poll abandon commits the + * user's own `git fetch` or `git branch -f` moved a ref away from, rewriting files on disk. The + * guard belongs to everything T3 runs on the user's behalf, never to their own jj invocations. + */ +const JJ_GLOBAL_ARGS = [ + "--no-pager", + "--color=never", + "--quiet", + "--config", + "git.abandon-unreachable-commits=false", + "--config", + `snapshot.max-new-file-size=${SNAPSHOT_MAX_NEW_FILE_SIZE_BYTES}`, +] as const; + +function configFlags(config: JjCommandOptions["config"]): string[] { + return config?.flatMap(([key, value]) => ["--config", `${key}=${value}`]) ?? []; +} + +function passthroughOptions(options: JjCommandOptions | undefined) { + return { + ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), + ...(options?.env !== undefined ? { env: options.env } : {}), + ...(options?.allowNonZeroExit !== undefined + ? { allowNonZeroExit: options.allowNonZeroExit } + : {}), + ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), + ...(options?.outputMode !== undefined ? { outputMode: options.outputMode } : {}), + ...(options?.appendTruncationMarker !== undefined + ? { appendTruncationMarker: options.appendTruncationMarker } + : {}), + }; +} + +function jjArgs(args: ReadonlyArray, options: JjCommandOptions | undefined): string[] { + return [ + ...JJ_GLOBAL_ARGS, + ...configFlags(options?.config), + ...(options?.ignoreWorkingCopy === true ? ["--ignore-working-copy"] : []), + ...args, + ]; +} + +function isStaleWorkspace(stderr: string): boolean { + const normalized = stderr.toLowerCase(); + return STALE_WORKSPACE_MARKERS.some((marker) => normalized.includes(marker)); +} + +/** + * Runs `jj` inside `cwd` (no `-R`: jj's `-R` demands an exact workspace root, and callers pass + * project subdirectories). Retries once through `jj workspace update-stale` when the workspace is + * stale. Warnings, hints and the large-file banner land on stderr, so only stdout may be parsed. + */ +export const jjCommand = Effect.fn("JjProcess.jjCommand")(function* ( + process: VcsProcess.VcsProcess["Service"], + operation: string, + cwd: string, + args: ReadonlyArray, + options?: JjCommandOptions, +): Effect.fn.Return { + const input = { + operation, + command: "jj", + args: jjArgs(args, options), + cwd, + ...passthroughOptions(options), + allowNonZeroExit: true, + } satisfies VcsProcess.VcsProcessInput; + + let result = yield* process.run(input); + + if (result.exitCode !== 0 && isStaleWorkspace(result.stderr)) { + yield* Effect.logWarning("jj workspace is stale, running `jj workspace update-stale`", { + cwd, + operation, + }); + yield* process.run({ + operation: `${operation}.updateStale`, + command: "jj", + args: jjArgs(["workspace", "update-stale"], undefined), + cwd, + allowNonZeroExit: true, + timeoutMs: UPDATE_STALE_TIMEOUT_MS, + }); + result = yield* process.run(input); + } + + if (result.stderr.toLowerCase().includes(LARGE_FILE_BANNER)) { + yield* Effect.logWarning("jj refused to snapshot some files", { + cwd, + stderr: result.stderr.slice(0, LOGGED_STDERR_MAX_CHARS), + }); + } + + if (result.exitCode !== 0 && options?.allowNonZeroExit !== true) { + return yield* VcsProcessExitError.fromProcessExit( + { operation, command: "jj", cwd, argumentCount: input.args.length }, + { + exitCode: result.exitCode, + stderr: result.stderr, + stderrTruncated: result.stderrTruncated, + }, + VcsProcess.classifyNonZeroExit("jj", result.stderr), + ); + } + + return result; +}); + +/** The `run(operation, cwd, args, options)` shim every jj module binds once against its process. */ +export const jjRunner = + (process: VcsProcess.VcsProcess["Service"]) => + (operation: string, cwd: string, args: ReadonlyArray, options?: JjCommandOptions) => + jjCommand(process, operation, cwd, args, options); + +// Named Git refs do not auto-export from secondary workspaces; hosting CLIs read those refs. +export const exportGitRefs = ( + process: VcsProcess.VcsProcess["Service"], + operation: string, + cwd: string, +) => + jjCommand(process, operation, cwd, ["git", "export"], { + ignoreWorkingCopy: true, + timeoutMs: 30_000, + }).pipe(Effect.asVoid); + +export interface ColocatedGitInput { + readonly gitDir: string; + readonly workTree?: string; + readonly cwd: string; +} + +/** + * Runs `git` against the colocated object store. The `--git-dir` is always explicit: a secondary jj + * workspace has no `.git` for git to discover from its cwd. The process is spawned in `cwd` so the + * relative paths `check-ignore --stdin` reads resolve against the directory being classified. + */ +export const colocatedGitCommand = ( + process: VcsProcess.VcsProcess["Service"], + operation: string, + input: ColocatedGitInput, + args: ReadonlyArray, + options?: Omit, +): Effect.Effect => + process.run({ + operation, + command: "git", + args: [ + "--git-dir", + input.gitDir, + ...(input.workTree !== undefined ? ["--work-tree", input.workTree] : []), + ...args, + ], + cwd: input.cwd, + ...passthroughOptions(options), + }); diff --git a/apps/server/src/vcs/JjRepo.test.ts b/apps/server/src/vcs/JjRepo.test.ts new file mode 100644 index 000000000000..6e133f8bd095 --- /dev/null +++ b/apps/server/src/vcs/JjRepo.test.ts @@ -0,0 +1,216 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { + findVcsMarkerRoot, + mainWorkspaceRootFromRepoPointer, + resolveJjRepoPaths, +} from "./JjRepo.ts"; +import { createJjRepo, describeJj, runJj } from "./testing/JjTestSupport.ts"; + +const withTempDir = ( + use: (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly root: string; + }) => Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const created = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-jj-repo-" }); + const root = yield* fileSystem.realPath(created); + return yield* use({ fileSystem, path, root }); + }).pipe(Effect.provide(NodeServices.layer)); + +/** The on-disk shape of a colocated main workspace: a `.jj` directory beside a `.git` directory. */ +const makeColocatedRoot = (fileSystem: FileSystem.FileSystem, path: Path.Path, root: string) => + Effect.gen(function* () { + yield* fileSystem.makeDirectory(path.join(root, ".jj", "repo"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(root, ".git"), { recursive: true }); + }); + +describe("findVcsMarkerRoot", () => { + it.effect("resolves a subdirectory of a colocated workspace to its root", () => + withTempDir(({ fileSystem, path, root }) => + Effect.gen(function* () { + yield* makeColocatedRoot(fileSystem, path, root); + const nested = path.join(root, "src", "deep"); + yield* fileSystem.makeDirectory(nested, { recursive: true }); + + assert.deepStrictEqual(yield* findVcsMarkerRoot(fileSystem, path, nested), { + root, + marker: "jj", + }); + }), + ), + ); + + it.effect("gives a nested git worktree to git, not to the jj workspace above it", () => + withTempDir(({ fileSystem, path, root }) => + Effect.gen(function* () { + yield* makeColocatedRoot(fileSystem, path, root); + const worktree = path.join(root, "worktrees", "thread"); + yield* fileSystem.makeDirectory(worktree, { recursive: true }); + // `git worktree add` writes `.git` as a file holding a gitdir pointer. + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${path.join(root, ".git", "worktrees", "thread")}\n`, + ); + + assert.deepStrictEqual(yield* findVcsMarkerRoot(fileSystem, path, worktree), { + root: worktree, + marker: "git", + }); + }), + ), + ); + + it.effect("gives a nested plain git repository to git", () => + withTempDir(({ fileSystem, path, root }) => + Effect.gen(function* () { + yield* makeColocatedRoot(fileSystem, path, root); + const nestedRepo = path.join(root, "vendor", "lib"); + yield* fileSystem.makeDirectory(path.join(nestedRepo, ".git"), { recursive: true }); + + assert.deepStrictEqual(yield* findVcsMarkerRoot(fileSystem, path, nestedRepo), { + root: nestedRepo, + marker: "git", + }); + }), + ), + ); + + it.effect("returns null for a path that no longer exists instead of walking up", () => + withTempDir(({ fileSystem, path, root }) => + Effect.gen(function* () { + yield* makeColocatedRoot(fileSystem, path, root); + const missing = path.join(root, "worktrees", "deleted-thread"); + + assert.equal(yield* findVcsMarkerRoot(fileSystem, path, missing), null); + }), + ), + ); + + it.effect("returns null when no marker exists above the path", () => + withTempDir(({ fileSystem, path, root }) => + Effect.gen(function* () { + const plain = path.join(root, "plain"); + yield* fileSystem.makeDirectory(plain, { recursive: true }); + + assert.equal(yield* findVcsMarkerRoot(fileSystem, path, plain), null); + }), + ), + ); + + it.effect("normalises the root through symlinks", () => + withTempDir(({ fileSystem, path, root }) => + Effect.gen(function* () { + const real = path.join(root, "real"); + yield* fileSystem.makeDirectory(path.join(real, ".jj", "repo"), { recursive: true }); + const link = path.join(root, "link"); + yield* fileSystem.symlink(real, link); + + assert.deepStrictEqual(yield* findVcsMarkerRoot(fileSystem, path, link), { + root: real, + marker: "jj", + }); + }), + ), + ); +}); + +describe("mainWorkspaceRootFromRepoPointer", () => { + it("resolves a relative pointer against the workspace's own .jj directory", () => { + assert.equal( + mainWorkspaceRootFromRepoPointer("/lab/ws/thread", "../../../work/.jj/repo\n"), + "/lab/work", + ); + }); + + it("resolves an absolute pointer", () => { + assert.equal( + mainWorkspaceRootFromRepoPointer("/lab/ws/thread", "/lab/work/.jj/repo"), + "/lab/work", + ); + }); +}); + +describe("resolveJjRepoPaths", () => { + it.effect("reports a colocated main workspace", () => + withTempDir(({ fileSystem, path, root }) => + Effect.gen(function* () { + yield* makeColocatedRoot(fileSystem, path, root); + + assert.deepStrictEqual(yield* resolveJjRepoPaths(fileSystem, path, root), { + workspaceRoot: root, + mainWorkspaceRoot: root, + gitDir: path.join(root, ".git"), + isSecondaryWorkspace: false, + }); + }), + ), + ); + + it.effect("reports no git directory for a non-colocated repository", () => + withTempDir(({ fileSystem, path, root }) => + Effect.gen(function* () { + yield* fileSystem.makeDirectory(path.join(root, ".jj", "repo"), { recursive: true }); + + const paths = yield* resolveJjRepoPaths(fileSystem, path, root); + assert.equal(paths.gitDir, null); + assert.equal(paths.isSecondaryWorkspace, false); + }), + ), + ); + + it.effect("follows a secondary workspace's relative repo pointer to the main workspace", () => + withTempDir(({ fileSystem, path, root }) => + Effect.gen(function* () { + const main = path.join(root, "work"); + yield* makeColocatedRoot(fileSystem, path, main); + const secondary = path.join(root, "ws", "thread"); + yield* fileSystem.makeDirectory(path.join(secondary, ".jj"), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(secondary, ".jj", "repo"), + "../../../work/.jj/repo", + ); + + assert.deepStrictEqual(yield* resolveJjRepoPaths(fileSystem, path, secondary), { + workspaceRoot: secondary, + mainWorkspaceRoot: main, + gitDir: path.join(main, ".git"), + isSecondaryWorkspace: true, + }); + }), + ), + ); +}); + +describeJj("JjRepo against a real repository", () => { + it.effect("resolves the colocated git store from a workspace jj created", () => + withTempDir(({ fileSystem, path, root }) => + Effect.gen(function* () { + const main = path.join(root, "work"); + yield* fileSystem.makeDirectory(main, { recursive: true }); + yield* createJjRepo(main); + yield* fileSystem.writeFileString(path.join(main, "a.txt"), "a\n"); + yield* runJj(main, ["commit", "-m", "first"]); + const secondary = path.join(root, "ws", "thread"); + yield* fileSystem.makeDirectory(path.dirname(secondary), { recursive: true }); + yield* runJj(main, ["workspace", "add", "--name", "t3-thread", secondary]); + + const marker = yield* findVcsMarkerRoot(fileSystem, path, secondary); + assert.equal(marker?.marker, "jj"); + + const paths = yield* resolveJjRepoPaths(fileSystem, path, secondary); + assert.equal(paths.mainWorkspaceRoot, main); + assert.equal(paths.gitDir, path.join(main, ".git")); + assert.equal(paths.isSecondaryWorkspace, true); + }), + ), + ); +}); diff --git a/apps/server/src/vcs/JjRepo.ts b/apps/server/src/vcs/JjRepo.ts new file mode 100644 index 000000000000..0035f8a110cc --- /dev/null +++ b/apps/server/src/vcs/JjRepo.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; + +import { type VcsError, VcsRepositoryDetectionError } from "@t3tools/contracts"; + +export type VcsMarker = "jj" | "git"; + +export interface VcsMarkerRoot { + /** Nearest ancestor of `cwd` (inclusive) holding `.jj` or `.git`. Realpath-normalised. */ + readonly root: string; + /** `"jj"` when that directory holds `.jj` (colocated or not); `"git"` when it holds only `.git`. */ + readonly marker: VcsMarker; +} + +export interface JjRepoPaths { + /** Workspace root for `cwd`, the directory holding the `.jj` this cwd belongs to. */ + readonly workspaceRoot: string; + /** Root of the `default` workspace, the only one that can hold `.git`. */ + readonly mainWorkspaceRoot: string; + /** `/.git` when the repo is colocated; `null` otherwise. */ + readonly gitDir: string | null; + /** True when `/.jj/repo` is a file (a secondary workspace). */ + readonly isSecondaryWorkspace: boolean; +} + +const isDirectory = (fileSystem: FileSystem.FileSystem, candidate: string) => + fileSystem.stat(candidate).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orElseSucceed(() => false), + ); + +const exists = (fileSystem: FileSystem.FileSystem, candidate: string) => + fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + +/** + * Walks up from `cwd` and stops at the FIRST directory holding `.jj` or `.git`. Pure filesystem, no + * subprocess. A nearer `.git` outranks a `.jj` further up, so a git worktree nested inside a jj repo + * keeps its own driver and no operation ever reaches across into the outer workspace. + * + * `null` when `cwd` itself does not exist. A deleted thread directory must never walk up into an + * unrelated repository above it, and it is null when neither marker is found up to the filesystem root. + */ +export const findVcsMarkerRoot = Effect.fn("JjRepo.findVcsMarkerRoot")(function* ( + fileSystem: FileSystem.FileSystem, + path: Path.Path, + cwd: string, +): Effect.fn.Return { + if (!(yield* isDirectory(fileSystem, cwd))) { + return null; + } + + let current = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); + + for (;;) { + if (yield* isDirectory(fileSystem, path.join(current, ".jj"))) { + return { root: current, marker: "jj" }; + } + if (yield* exists(fileSystem, path.join(current, ".git"))) { + return { root: current, marker: "git" }; + } + + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +}); + +/** + * PURE. `/.jj/repo` in a secondary workspace is a pointer to the main repo's + * `.jj/repo`, usually relative, and relative to `/.jj`, not to the workspace root. + */ +export function mainWorkspaceRootFromRepoPointer( + workspaceRoot: string, + repoPointerContents: string, +): string { + const pointer = repoPointerContents.trim(); + const resolved = NodePath.isAbsolute(pointer) + ? pointer + : NodePath.resolve(NodePath.join(workspaceRoot, ".jj"), pointer); + return NodePath.dirname(NodePath.dirname(resolved)); +} + +export const resolveJjRepoPaths = Effect.fn("JjRepo.resolveJjRepoPaths")(function* ( + fileSystem: FileSystem.FileSystem, + path: Path.Path, + workspaceRoot: string, +): Effect.fn.Return { + const resolvedWorkspaceRoot = yield* fileSystem + .realPath(workspaceRoot) + .pipe(Effect.orElseSucceed(() => workspaceRoot)); + const repoPointer = path.join(resolvedWorkspaceRoot, ".jj", "repo"); + const isMainWorkspace = yield* isDirectory(fileSystem, repoPointer); + + let mainWorkspaceRoot = resolvedWorkspaceRoot; + if (!isMainWorkspace) { + const pointerContents = yield* fileSystem.readFileString(repoPointer).pipe( + Effect.mapError( + (cause) => + new VcsRepositoryDetectionError({ + operation: "JjRepo.resolveJjRepoPaths", + cwd: workspaceRoot, + detail: "Could not read the Jujutsu workspace repository pointer.", + cause, + }), + ), + ); + const pointedRoot = mainWorkspaceRootFromRepoPointer(resolvedWorkspaceRoot, pointerContents); + mainWorkspaceRoot = yield* fileSystem + .realPath(pointedRoot) + .pipe(Effect.orElseSucceed(() => pointedRoot)); + } + + const gitDirCandidate = path.join(mainWorkspaceRoot, ".git"); + const colocated = yield* isDirectory(fileSystem, gitDirCandidate); + + return { + workspaceRoot: resolvedWorkspaceRoot, + mainWorkspaceRoot, + gitDir: colocated ? gitDirCandidate : null, + isSecondaryWorkspace: !isMainWorkspace, + } satisfies JjRepoPaths; +}); diff --git a/apps/server/src/vcs/JjReviewDiff.test.ts b/apps/server/src/vcs/JjReviewDiff.test.ts new file mode 100644 index 000000000000..5a8f74f27644 --- /dev/null +++ b/apps/server/src/vcs/JjReviewDiff.test.ts @@ -0,0 +1,373 @@ +import { assert, it } from "@effect/vitest"; +import type { + ReviewDiffFileContentsInput, + ReviewDiffFileContentsResult, + ReviewDiffPreviewInput, + ReviewDiffPreviewResult, + VcsError, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; + +import { commitId, describeJj, runGit, runJj, withJjRepo, write } from "./testing/JjTestSupport.ts"; + +interface RepoContext { + readonly getDiffPreview: ( + input: ReviewDiffPreviewInput, + ) => Effect.Effect; + readonly getDiffFileContents: ( + input: ReviewDiffFileContentsInput, + ) => Effect.Effect; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly base: string; + readonly root: string; +} + +const withRepo = ( + use: (context: RepoContext) => Effect.Effect, +) => + withJjRepo({ prefix: "t3-jj-review-" }, (repo) => { + const getDiffPreview = repo.driver.getDiffPreview; + const getDiffFileContents = repo.driver.getDiffFileContents; + if (!getDiffPreview || !getDiffFileContents) { + assert.fail("The Jujutsu driver exposes no review diff operations."); + } + return use({ ...repo, getDiffPreview, getDiffFileContents }); + }); + +/** Publishes `main` through the colocated Git store, so `main@origin` and `trunk()` both resolve. */ +const addOriginWithMain = (context: RepoContext) => + Effect.gen(function* () { + const origin = context.path.join(context.base, "origin.git"); + yield* context.fileSystem.makeDirectory(origin, { recursive: true }); + yield* runGit(origin, ["init", "--bare", "--quiet"]); + yield* runJj(context.root, ["bookmark", "create", "main", "-r", "@-"]); + yield* runGit(context.root, ["remote", "add", "origin", origin]); + yield* runGit(context.root, ["push", "--quiet", "origin", "main"]); + yield* runJj(context.root, ["git", "fetch"]); + }); + +describeJj("JjReviewDiff preview", () => { + it.effect("keeps complete statistics and expands files beyond the preview limit", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "base.txt", "base\n"); + yield* runJj(context.root, ["commit", "-m", "base"]); + yield* addOriginWithMain(context); + yield* write(context, "a-large.txt", "large line\n".repeat(20_000)); + yield* write(context, "z-last.txt", "last\n"); + const preview = yield* context.getDiffPreview({ cwd: context.root }); + const source = preview.sources[0]!; + assert.isTrue(source.truncated); + assert.deepEqual(source.files, [ + { path: "a-large.txt", previousPath: null, additions: 20_000, deletions: 0 }, + { path: "z-last.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + yield* write(context, "z-last.txt", "last\nanother\n"); + const changed = yield* context.getDiffPreview({ cwd: context.root }); + assert.equal(changed.sources[0]!.diff, source.diff); + assert.notEqual(changed.sources[0]!.diffHash, source.diffHash); + const expanded = yield* context.getDiffPreview({ + cwd: context.root, + file: { path: "a-large.txt", previousPath: null, sourceKind: "working-tree" }, + }); + assert.isFalse(expanded.sources[0]!.truncated); + assert.notInclude(expanded.sources[0]!.diff, "z-last.txt"); + assert.deepEqual(expanded.sources[1]!.files, []); + assert.equal(expanded.sources[1]!.diff, ""); + }), + ), + ); + + it.effect("filters a renamed file literally and keeps NUL-delimited paths intact", () => + withRepo( + Effect.fnUntraced(function* (context) { + const oldPath = "old\tname.txt"; + const newPath = "new\n[name].txt"; + yield* write(context, oldPath, "same content\n".repeat(20)); + yield* runJj(context.root, ["commit", "-m", "base"]); + yield* addOriginWithMain(context); + yield* context.fileSystem.rename( + context.path.join(context.root, oldPath), + context.path.join(context.root, newPath), + ); + yield* write(context, "other.txt", "other\n"); + const preview = yield* context.getDiffPreview({ + cwd: context.root, + file: { path: newPath, previousPath: oldPath, sourceKind: "branch-range" }, + }); + assert.equal(preview.sources[0]!.diff, ""); + assert.deepEqual(preview.sources[1]!.files, [ + { path: newPath, previousPath: oldPath, additions: 0, deletions: 0 }, + ]); + assert.notInclude(preview.sources[1]!.diff, "other.txt"); + }), + ), + ); + + it.effect("returns a working-copy source and a base source against the default bookmark", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "v1\n"); + yield* runJj(context.root, ["commit", "-m", "base"]); + yield* addOriginWithMain(context); + yield* write(context, "a.txt", "v2\n"); + yield* write(context, "untracked.txt", "new file\n"); + + const preview = yield* context.getDiffPreview({ cwd: context.root }); + + const [workingTree, branchRange] = preview.sources; + assert.equal(preview.sources.length, 2); + assert.deepInclude(workingTree, { + id: "working-tree", + kind: "working-tree", + title: "Working copy", + baseRef: "@-", + headRef: "@", + truncated: false, + }); + // jj auto-snapshots, so an untracked file needs no index dance to reach the diff. + assert.include(workingTree?.diff ?? "", "untracked.txt"); + assert.include(workingTree?.diff ?? "", "-v1"); + assert.include(workingTree?.diff ?? "", "+v2"); + assert.deepInclude(branchRange, { + id: "branch-range", + kind: "branch-range", + title: "Against main", + baseRef: yield* commitId(context.root, "main"), + headRef: "@", + }); + assert.include(branchRange?.diff ?? "", "+v2"); + + // An explicit remote base ref reaches the resolver in jj's own revset form. + const againstRemote = yield* context.getDiffPreview({ + cwd: context.root, + baseRef: "origin/main", + }); + assert.deepInclude(againstRemote.sources[1], { + title: "Against origin/main", + baseRef: yield* commitId(context.root, "main@origin"), + }); + }), + ), + ); + + it.effect("succeeds with an empty base source when no base resolves", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "v1\n"); + + const preview = yield* context.getDiffPreview({ cwd: context.root }); + + const branchRange = preview.sources[1]; + assert.deepInclude(branchRange, { + id: "branch-range", + kind: "branch-range", + title: "Against base branch", + baseRef: null, + diff: "", + truncated: false, + }); + assert.include(preview.sources[0]?.diff ?? "", "+v1"); + }), + ), + ); + + it.effect("compares a merge change with all parents and expands its own edits", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "base.txt", "base\n"); + yield* runJj(context.root, ["commit", "-m", "base"]); + const base = yield* commitId(context.root, "@-"); + yield* write(context, "left.txt", "left\n"); + yield* runJj(context.root, ["commit", "-m", "left"]); + const left = yield* commitId(context.root, "@-"); + yield* runJj(context.root, ["new", base, "-m", "right"]); + yield* write(context, "right.txt", "inherited\n"); + yield* runJj(context.root, ["commit", "-m", "right"]); + const right = yield* commitId(context.root, "@-"); + yield* runJj(context.root, ["new", left, right, "-m", "merge"]); + + const clean = yield* context.getDiffPreview({ cwd: context.root }); + assert.deepEqual(clean.sources[0]!.files, []); + assert.equal(clean.sources[0]!.diff, ""); + + yield* write(context, "right.txt", "edited without newline"); + const edited = yield* context.getDiffPreview({ cwd: context.root }); + assert.deepEqual(edited.sources[0]!.files, [ + { path: "right.txt", previousPath: null, additions: 1, deletions: 1 }, + ]); + assert.include(edited.sources[0]!.diff, "-inherited"); + assert.notInclude(edited.sources[0]!.diff, "left.txt"); + assert.deepEqual( + yield* context.getDiffFileContents({ + cwd: context.root, + sourceKind: "working-tree", + baseRef: "@-", + headRef: "@", + changeType: "change", + oldPath: "right.txt", + newPath: "right.txt", + }), + { oldContents: "inherited\n", newContents: "edited without newline" }, + ); + }), + ), + ); + + it.effect("keeps binary metadata and empty-file expansion correct in merge changes", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "base.txt", "base\n"); + yield* runJj(context.root, ["commit", "-m", "base"]); + const base = yield* commitId(context.root, "@-"); + yield* write(context, "left.txt", "left\n"); + yield* runJj(context.root, ["commit", "-m", "left"]); + const left = yield* commitId(context.root, "@-"); + yield* runJj(context.root, ["new", base, "-m", "right"]); + yield* write(context, "empty.txt", ""); + yield* runJj(context.root, ["commit", "-m", "right"]); + const right = yield* commitId(context.root, "@-"); + yield* runJj(context.root, ["new", left, right, "-m", "merge"]); + yield* write(context, "binary.bin", "a\0b"); + yield* context.fileSystem.remove(context.path.join(context.root, "empty.txt")); + const preview = yield* context.getDiffPreview({ cwd: context.root }); + assert.deepEqual(preview.sources[0]!.files, [ + { path: "binary.bin", previousPath: null, additions: 0, deletions: 0 }, + { path: "empty.txt", previousPath: null, additions: 0, deletions: 0 }, + ]); + assert.deepEqual( + yield* context.getDiffFileContents({ + cwd: context.root, + sourceKind: "working-tree", + changeType: "deleted", + baseRef: null, + headRef: null, + oldPath: "empty.txt", + newPath: "empty.txt", + }), + { oldContents: "", newContents: "" }, + ); + const failure = yield* context + .getDiffFileContents({ + cwd: context.root, + sourceKind: "working-tree", + changeType: "new", + baseRef: null, + headRef: null, + oldPath: "binary.bin", + newPath: "binary.bin", + }) + .pipe(Effect.flip); + assert.equal(failure._tag, "VcsProcessExitError"); + }), + ), + ); + + it.effect("marks a conflicted working copy in the source title", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "base\n"); + yield* runJj(context.root, ["commit", "-m", "base"]); + const baseCommit = yield* commitId(context.root, "@-"); + yield* write(context, "a.txt", "one\n"); + yield* runJj(context.root, ["commit", "-m", "one"]); + const sideOne = yield* commitId(context.root, "@-"); + yield* runJj(context.root, ["new", baseCommit]); + yield* write(context, "a.txt", "two\n"); + yield* runJj(context.root, ["commit", "-m", "two"]); + const sideTwo = yield* commitId(context.root, "@-"); + yield* runJj(context.root, ["new", sideOne, sideTwo]); + + const preview = yield* context.getDiffPreview({ cwd: context.root }); + + assert.equal(preview.sources[0]?.title, "Working copy (conflicted)"); + assert.notInclude(preview.sources[0]?.diff ?? "", ".jjconflict-"); + assert.notInclude(preview.sources[0]?.diff ?? "", "JJ-CONFLICT-README"); + }), + ), + ); +}); + +describeJj("JjReviewDiff file contents", () => { + it.effect("expands changed, new, deleted, and renamed files from the working copy", () => + withRepo( + Effect.fnUntraced(function* (context) { + const renamedBody = Array.from({ length: 20 }, (_, index) => `line ${index}\n`).join(""); + yield* write(context, "changed.txt", "v1\n"); + yield* write(context, "deleted.txt", "gone soon\n"); + yield* write(context, "rename-old.txt", renamedBody); + yield* runJj(context.root, ["commit", "-m", "base"]); + + yield* write(context, "changed.txt", "v2\n"); + yield* write(context, "added.txt", "brand new\n"); + yield* context.fileSystem.remove(context.path.join(context.root, "deleted.txt")); + yield* context.fileSystem.rename( + context.path.join(context.root, "rename-old.txt"), + context.path.join(context.root, "rename-new.txt"), + ); + + const expand = ( + input: Pick, + ) => + context.getDiffFileContents({ + cwd: context.root, + sourceKind: "working-tree", + baseRef: "@-", + headRef: "@", + ...input, + }); + + assert.deepStrictEqual( + yield* expand({ changeType: "change", oldPath: "changed.txt", newPath: "changed.txt" }), + { oldContents: "v1\n", newContents: "v2\n" }, + ); + assert.deepStrictEqual( + yield* expand({ changeType: "new", oldPath: "added.txt", newPath: "added.txt" }), + { oldContents: "", newContents: "brand new\n" }, + ); + assert.deepStrictEqual( + yield* expand({ changeType: "deleted", oldPath: "deleted.txt", newPath: "deleted.txt" }), + { oldContents: "gone soon\n", newContents: "" }, + ); + assert.deepStrictEqual( + yield* expand({ + changeType: "rename-pure", + oldPath: "rename-old.txt", + newPath: "rename-new.txt", + }), + { oldContents: renamedBody, newContents: renamedBody }, + ); + }), + ), + ); + + it.effect("expands a branch-range file against the commit ids the preview handed out", () => + withRepo( + Effect.fnUntraced(function* (context) { + yield* write(context, "a.txt", "v1\n"); + yield* runJj(context.root, ["commit", "-m", "base"]); + yield* addOriginWithMain(context); + yield* write(context, "a.txt", "v2\n"); + + const preview = yield* context.getDiffPreview({ cwd: context.root }); + const branchRange = preview.sources[1]; + + assert.deepStrictEqual( + yield* context.getDiffFileContents({ + cwd: context.root, + sourceKind: "branch-range", + changeType: "change", + baseRef: branchRange?.baseRef ?? null, + headRef: branchRange?.headRef ?? null, + oldPath: "a.txt", + newPath: "a.txt", + }), + { oldContents: "v1\n", newContents: "v2\n" }, + ); + }), + ), + ); +}); diff --git a/apps/server/src/vcs/JjReviewDiff.ts b/apps/server/src/vcs/JjReviewDiff.ts new file mode 100644 index 000000000000..e3edc63f4064 --- /dev/null +++ b/apps/server/src/vcs/JjReviewDiff.ts @@ -0,0 +1,409 @@ +import * as NodeCrypto from "node:crypto"; + +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import { + type ReviewDiffFileStat, + type ReviewDiffPreviewInput, + type ReviewDiffPreviewSource, + type VcsError, + VcsProcessExitError, +} from "@t3tools/contracts"; +import { parseReviewNumstat, PATCH_RENDER_PREFIX_ARGS } from "./GitVcsDriverCore.ts"; +import { EMPTY_TREE_OID, JJ_CONFLICT_PATHSPECS } from "./JjCheckpoints.ts"; +import { colocatedGitCommand, jjCommand } from "./JjProcess.ts"; +import type { JjChange } from "./JjVcsDriver.ts"; +import { literalFilesetPath, refNameToRevset } from "./JjRevset.ts"; +import type * as VcsDriver from "./VcsDriver.ts"; +import type * as VcsProcess from "./VcsProcess.ts"; + +export interface JjReviewDiffDeps { + readonly process: VcsProcess.VcsProcess["Service"]; + /** Fails unless jj can operate here; yields the colocated git store. */ + readonly ensureGitDir: (operation: string, cwd: string) => Effect.Effect; + readonly currentChange: (cwd: string) => Effect.Effect; + readonly changeAt: (cwd: string, revset: string) => Effect.Effect; + readonly listRemoteNames: (cwd: string) => Effect.Effect, VcsError>; + readonly resolveDefaultBookmark: (cwd: string) => Effect.Effect; +} + +export type JjReviewDiffOps = Pick< + Required, + "getDiffPreview" | "getDiffFileContents" +>; + +const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; +const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; +const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const TRUNK_REVSET = "trunk()"; + +function hashDiff(diff: string, files: ReadonlyArray): string { + return NodeCrypto.createHash("sha256") + .update(JSON.stringify([diff, files]), "utf8") + .digest("hex"); +} + +function fullContextContents(patch: string): { oldContents: string; newContents: string } | null { + let inHunk = false; + let previousPrefix = ""; + let oldContents = ""; + let newContents = ""; + for (const line of patch.split("\n")) { + if (line.startsWith("@@ ")) { + inHunk = true; + continue; + } + if (!inHunk) continue; + const prefix = line[0]; + if (line === "\\ No newline at end of file") { + if (previousPrefix !== "+") oldContents = oldContents.slice(0, -1); + if (previousPrefix !== "-") newContents = newContents.slice(0, -1); + } else if (prefix === " " || prefix === "-" || prefix === "+") { + if (prefix !== "+") oldContents += `${line.slice(1)}\n`; + if (prefix !== "-") newContents += `${line.slice(1)}\n`; + previousPrefix = prefix; + } + } + return inHunk ? { oldContents, newContents } : null; +} + +export const makeJjReviewDiff = (deps: JjReviewDiffDeps): JjReviewDiffOps => { + const runDiff = Effect.fn("JjVcsDriver.getDiffPreview.diff")(function* ( + operation: string, + gitDir: string, + cwd: string, + range: ReadonlyArray, + ignoreWhitespace: boolean | undefined, + file: ReviewDiffPreviewInput["file"], + ) { + const args = [ + "diff", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, + "--find-renames", + ...(ignoreWhitespace === true ? ["--ignore-all-space"] : []), + ]; + const paths = [ + ...JJ_CONFLICT_PATHSPECS.filter((path) => !file || path !== "."), + ...(file + ? [file.path, ...(file.previousPath ? [file.previousPath] : [])].map( + (path) => `:(top,literal)${path}`, + ) + : []), + ]; + const stats = yield* colocatedGitCommand( + deps.process, + operation, + { gitDir, cwd }, + [...args, "--numstat", "-z", ...range, ...paths], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ); + const files = parseReviewNumstat(stats.stdout); + if (files.length === 0) return { stdout: "", stdoutTruncated: false, files }; + const patch = yield* colocatedGitCommand( + deps.process, + operation, + { gitDir, cwd }, + [...args, "--patch", ...range, ...paths], + { + maxOutputBytes: file + ? REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES + : REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, + outputMode: "truncate", + appendTruncationMarker: true, + }, + ); + return { ...patch, files }; + }); + + const runMergedParentDiff = Effect.fn("JjVcsDriver.getDiffPreview.mergedParents")(function* ( + operation: string, + gitDir: string, + cwd: string, + commitId: string, + ignoreWhitespace: boolean | undefined, + file: ReviewDiffPreviewInput["file"], + ) { + const args = [ + "diff", + "-r", + commitId, + "--git", + ...(ignoreWhitespace ? ["--ignore-all-space"] : []), + ]; + const paths = file + ? [ + "--", + literalFilesetPath(file.path), + ...(file.previousPath ? [literalFilesetPath(file.previousPath)] : []), + ] + : []; + // A zero-context native patch supplies complete numstat without materializing parent trees. + const metadata = yield* jjCommand( + deps.process, + operation, + cwd, + [...args, "--context=0", ...paths], + { ignoreWorkingCopy: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ); + const stats = + metadata.stdout === "" + ? "" + : (yield* colocatedGitCommand( + deps.process, + operation, + { gitDir, cwd }, + ["apply", "--numstat", "-z"], + { stdin: metadata.stdout, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + )).stdout; + const files = parseReviewNumstat(stats); + if (files.length === 0) return { stdout: "", stdoutTruncated: false, files }; + const patch = yield* jjCommand(deps.process, operation, cwd, [...args, ...paths], { + ignoreWorkingCopy: true, + maxOutputBytes: file ? REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES : REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, + outputMode: "truncate", + appendTruncationMarker: true, + }); + return { ...patch, files }; + }); + + const resolveBaseChange = Effect.fn("JjVcsDriver.getDiffPreview.resolveBase")(function* ( + cwd: string, + baseRef: string | undefined, + ): Effect.fn.Return<{ readonly label: string; readonly change: JjChange | null }, VcsError> { + const label = baseRef ?? (yield* deps.resolveDefaultBookmark(cwd)) ?? TRUNK_REVSET; + const revset = + label === TRUNK_REVSET + ? TRUNK_REVSET + : refNameToRevset(label, yield* deps.listRemoteNames(cwd)); + return { label, change: yield* deps.changeAt(cwd, revset) }; + }); + + const getDiffPreview: JjReviewDiffOps["getDiffPreview"] = Effect.fn("JjVcsDriver.getDiffPreview")( + function* (input) { + const operation = "JjVcsDriver.getDiffPreview"; + const gitDir = yield* deps.ensureGitDir(operation, input.cwd); + + // jj auto-snapshots, so untracked non-ignored files are already inside `@`: no index dance. + const change = yield* deps.currentChange(input.cwd); + const workingTreeBase = change.parentCommitIds[0] ?? EMPTY_TREE_OID; + const empty = { stdout: "", stdoutTruncated: false, files: [] }; + const workingTreeResult = + input.file?.sourceKind === "branch-range" + ? empty + : change.parentCommitIds.length > 1 + ? yield* runMergedParentDiff( + operation, + gitDir, + input.cwd, + change.commitId, + input.ignoreWhitespace, + input.file, + ) + : yield* runDiff( + operation, + gitDir, + input.cwd, + [workingTreeBase, change.commitId], + input.ignoreWhitespace, + input.file, + ); + + const base = yield* resolveBaseChange(input.cwd, input.baseRef); + const baseResult = + base.change === null || input.file?.sourceKind === "working-tree" + ? empty + : yield* runDiff( + operation, + gitDir, + input.cwd, + [`${base.change.commitId}...${change.commitId}`], + input.ignoreWhitespace, + input.file, + ); + + const sources: ReadonlyArray = [ + { + id: "working-tree", + kind: "working-tree", + // The git tree of a conflicted commit holds one arbitrary side of each conflicted file, + // not the markers on disk, so the client must not present it as the file. + title: change.conflict ? "Working copy (conflicted)" : "Working copy", + baseRef: "@-", + headRef: "@", + diff: workingTreeResult.stdout, + files: workingTreeResult.files, + diffHash: hashDiff(workingTreeResult.stdout, workingTreeResult.files), + truncated: workingTreeResult.stdoutTruncated, + }, + { + id: "branch-range", + kind: "branch-range", + title: base.change === null ? "Against base branch" : `Against ${base.label}`, + baseRef: base.change?.commitId ?? null, + headRef: "@", + diff: baseResult.stdout, + files: baseResult.files, + diffHash: hashDiff(baseResult.stdout, baseResult.files), + truncated: baseResult.stdoutTruncated, + }, + ]; + + return { cwd: input.cwd, generatedAt: yield* DateTime.now, sources }; + }, + ); + + const resolveFileRevision = Effect.fn("JjVcsDriver.getDiffFileContents.resolveRevision")( + function* ( + cwd: string, + ref: string, + workingCopy: Effect.Effect, + ): Effect.fn.Return { + if (ref === "@") { + return (yield* workingCopy).commitId; + } + if (ref === "@-") { + return (yield* workingCopy).parentCommitIds[0] ?? EMPTY_TREE_OID; + } + const change = yield* deps.changeAt( + cwd, + refNameToRevset(ref, yield* deps.listRemoteNames(cwd)), + ); + return change?.commitId ?? null; + }, + ); + + const readFileAtRevision = Effect.fn("JjVcsDriver.getDiffFileContents.show")(function* ( + operation: string, + gitDir: string, + cwd: string, + revision: string, + relativePath: string, + ): Effect.fn.Return { + if (revision === EMPTY_TREE_OID) { + return ""; + } + const result = yield* colocatedGitCommand( + deps.process, + operation, + { gitDir, cwd }, + ["show", `${revision}:${relativePath}`], + { maxOutputBytes: REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES }, + ); + if (result.stdout.includes("\0")) { + return yield* Effect.fail( + new VcsProcessExitError({ + operation, + command: "git show", + cwd, + exitCode: 0, + detail: `Cannot expand binary file '${relativePath}'.`, + }), + ); + } + return result.stdout; + }); + + const getDiffFileContents: JjReviewDiffOps["getDiffFileContents"] = Effect.fn( + "JjVcsDriver.getDiffFileContents", + )(function* (input) { + const operation = "JjVcsDriver.getDiffFileContents"; + const gitDir = yield* deps.ensureGitDir(operation, input.cwd); + + const baseRef = input.baseRef ?? (input.sourceKind === "working-tree" ? "@-" : null); + const headRef = input.headRef ?? (input.sourceKind === "working-tree" ? "@" : null); + if (baseRef === null || headRef === null) { + return yield* Effect.fail( + new VcsProcessExitError({ + operation, + command: "git show", + cwd: input.cwd, + exitCode: 1, + detail: "Diff file expansion requires both base and head refs.", + }), + ); + } + + // `@` and `@-` both read the working copy, and every read snapshots it, so share one. + const workingCopy = yield* Effect.cached(deps.currentChange(input.cwd)); + if (input.sourceKind === "working-tree" && baseRef === "@-" && headRef === "@") { + const change = yield* workingCopy; + if (change.parentCommitIds.length > 1) { + const patch = yield* jjCommand( + deps.process, + operation, + input.cwd, + [ + "diff", + "-r", + change.commitId, + "--git", + "--context=1048576", + "--", + literalFilesetPath(input.newPath), + ], + { ignoreWorkingCopy: true, maxOutputBytes: 2 * REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES }, + ); + if (/^Binary files /m.test(patch.stdout)) { + return yield* new VcsProcessExitError({ + operation, + command: "jj diff", + cwd: input.cwd, + exitCode: 0, + detail: `Cannot expand binary file '${input.newPath}'.`, + }); + } + const contents = fullContextContents(patch.stdout); + if (contents !== null) return contents; + // A pure rename or unchanged file has no hunks; both sides share the target contents. + if (input.changeType === "new" || input.changeType === "deleted") { + return { oldContents: "", newContents: "" }; + } + const contentsAtHead = yield* readFileAtRevision( + operation, + gitDir, + input.cwd, + change.commitId, + input.newPath, + ); + return { oldContents: contentsAtHead, newContents: contentsAtHead }; + } + } + + const [baseOid, headOid] = yield* Effect.all([ + resolveFileRevision(input.cwd, baseRef, workingCopy), + resolveFileRevision(input.cwd, headRef, workingCopy), + ]); + if (baseOid === null || headOid === null) { + return yield* Effect.fail( + new VcsProcessExitError({ + operation, + command: "git show", + cwd: input.cwd, + exitCode: 1, + detail: "Diff file expansion could not resolve the comparison revisions.", + }), + ); + } + + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readFileAtRevision(operation, gitDir, input.cwd, baseOid, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readFileAtRevision(operation, gitDir, input.cwd, headOid, input.newPath), + ], + { concurrency: 2 }, + ); + + return { oldContents, newContents }; + }); + + return { getDiffPreview, getDiffFileContents }; +}; diff --git a/apps/server/src/vcs/JjRevset.test.ts b/apps/server/src/vcs/JjRevset.test.ts new file mode 100644 index 000000000000..5dcf417252c8 --- /dev/null +++ b/apps/server/src/vcs/JjRevset.test.ts @@ -0,0 +1,42 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + localBookmarkRevset, + refNameToRevset, + remoteBookmarkRevset, + splitRemoteRefName, +} from "./JjRevset.ts"; + +describe("JjRevset", () => { + it("splits a ref name only against a known remote, longest first", () => { + assert.deepStrictEqual(splitRemoteRefName("origin/feature/x", ["origin"]), { + remote: "origin", + name: "feature/x", + }); + assert.deepStrictEqual(splitRemoteRefName("origin/fork/main", ["origin", "origin/fork"]), { + remote: "origin/fork", + name: "main", + }); + assert.equal(splitRemoteRefName("origin/main", []), null); + assert.equal(splitRemoteRefName("feature/x", ["origin"]), null); + assert.equal(splitRemoteRefName("origin/", ["origin"]), null); + }); + + it("converts a remote ref to jj's name@remote form", () => { + assert.equal(refNameToRevset("origin/main", ["origin"]), '"main"@"origin"'); + assert.equal(remoteBookmarkRevset("upstream", "feature/x"), '"feature/x"@"upstream"'); + assert.equal(remoteBookmarkRevset('fork"team', "release:next"), '"release:next"@"fork\\"team"'); + }); + + it("converts a bookmark name to an exact bookmark revset", () => { + assert.equal(refNameToRevset("feature/x", ["origin"]), 'bookmarks(exact:"feature/x")'); + assert.equal(localBookmarkRevset('weird"name\\'), 'bookmarks(exact:"weird\\"name\\\\")'); + }); + + it("passes revisions through unchanged", () => { + const commitId = "b17b3034179d638b3a97c41e17e7ffe25b8c4f74"; + assert.equal(refNameToRevset(commitId, ["origin"]), commitId); + const changeId = "yzsrwslzmszllqvknmplyrnqmplsmpym"; + assert.equal(refNameToRevset(changeId, ["origin"]), changeId); + }); +}); diff --git a/apps/server/src/vcs/JjRevset.ts b/apps/server/src/vcs/JjRevset.ts new file mode 100644 index 000000000000..7839fb64b6f7 --- /dev/null +++ b/apps/server/src/vcs/JjRevset.ts @@ -0,0 +1,61 @@ +/** + * `listRefs` emits git-shaped `/` rows so the shared client helpers keep working, and + * the clients hand those names straight back to `switchRef` / `createWorktree`. jj cannot resolve + * `origin/main` (its form is `main@origin`), so every name-to-revset conversion goes through here. + */ + +import { parseRemoteRefWithRemoteNames } from "../git/remoteRefs.ts"; + +const COMMIT_ID_PATTERN = /^[0-9a-f]{40}$/; +const CHANGE_ID_PATTERN = /^[k-z]{32}$/; + +/** Escapes `\` and `"` for a jj string literal. */ +export function escapeRevsetString(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +/** Splits `origin/feature/x` into `{ remote: "origin", name: "feature/x" }` for a known remote. */ +export function splitRemoteRefName( + refName: string, + remoteNames: ReadonlyArray, +): { readonly remote: string; readonly name: string } | null { + const parsed = parseRemoteRefWithRemoteNames( + refName, + remoteNames.toSorted((a, b) => b.length - a.length), + ); + return parsed === null ? null : { remote: parsed.remoteName, name: parsed.branchName }; +} + +/** + * `file:""` is jj's literal fileset form, git's `--literal-pathspecs` equivalent. Bare + * arguments are parsed as fileset *expressions*, so `a~b` silently becomes a difference and + * `a (1).png` fails to parse; a bare leading `-` is parsed as an option instead of a path. + */ +export function literalFilesetPath(filePath: string): string { + return `file:"${escapeRevsetString(filePath)}"`; +} + +/** `bookmarks(exact:"")` resolves a local bookmark unambiguously. */ +export function localBookmarkRevset(name: string): string { + return `bookmarks(exact:"${escapeRevsetString(name)}")`; +} + +/** Quoted `@` keeps bookmark punctuation out of the revset grammar. */ +export function remoteBookmarkRevset(remote: string, name: string): string { + return `"${escapeRevsetString(name)}"@"${escapeRevsetString(remote)}"`; +} + +/** + * Client ref name to jj revset. A commit id or change id is returned unchanged so a base handed + * over the wire as a revision still resolves. + */ +export function refNameToRevset(refName: string, remoteNames: ReadonlyArray): string { + const remoteRef = splitRemoteRefName(refName, remoteNames); + if (remoteRef) { + return remoteBookmarkRevset(remoteRef.remote, remoteRef.name); + } + if (COMMIT_ID_PATTERN.test(refName) || CHANGE_ID_PATTERN.test(refName)) { + return refName; + } + return localBookmarkRevset(refName); +} diff --git a/apps/server/src/vcs/JjVcsDriver.test.ts b/apps/server/src/vcs/JjVcsDriver.test.ts new file mode 100644 index 000000000000..432903ad6a9d --- /dev/null +++ b/apps/server/src/vcs/JjVcsDriver.test.ts @@ -0,0 +1,436 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as JjVcsDriver from "./JjVcsDriver.ts"; +import * as VcsProcess from "./VcsProcess.ts"; +import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts"; +import { + createJjRepo, + describeJj, + JjDriverLayer, + type JjRepoFixture, + type JjTestCommandError, + runGit, + runJj, + withJjRepo, +} from "./testing/JjTestSupport.ts"; + +const ContractLayer = JjVcsDriver.vcsLayer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +type JjContractError = JjTestCommandError | PlatformError.PlatformError; + +const withRepo = ( + use: (fixture: JjRepoFixture) => Effect.Effect, +) => withJjRepo({ prefix: "t3-jj-driver-" }, use); + +describeJj("Jujutsu VCS driver contract", () => { + it.effect( + "enables colocated jj in an existing Git repository without losing history or local files", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-jj-convert-" }); + yield* runGit(root, ["init", "--initial-branch=main"]); + yield* fileSystem.writeFileString(`${root}/committed.txt`, "committed\n"); + yield* runGit(root, ["add", "committed.txt"]); + yield* runGit(root, [ + "-c", + "user.name=T3 Test", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "original", + ]); + const originalCommit = (yield* runGit(root, ["rev-parse", "HEAD"])).trim(); + yield* fileSystem.writeFileString(`${root}/uncommitted.txt`, "local\n"); + + const driver = yield* JjVcsDriver.JjVcsDriver; + yield* driver.initRepository({ cwd: root, kind: "jj" }); + + assert.equal((yield* driver.detectRepository(root))?.kind, "jj"); + assert.equal( + (yield* runJj(root, ["log", "-r", "main", "--no-graph", "-T", "commit_id"])).trim(), + originalCommit, + ); + assert.equal(yield* fileSystem.readFileString(`${root}/uncommitted.txt`), "local\n"); + assert.equal((yield* runGit(root, ["rev-parse", "HEAD"])).trim(), originalCommit); + }).pipe(Effect.provide(JjDriverLayer)), + ); + + runVcsDriverContractSuite({ + name: "Jujutsu", + kind: "jj", + layer: ContractLayer, + fixture: { + createRepo: (cwd) => createJjRepo(cwd), + writeFile: (cwd, relativePath, contents) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); + yield* fileSystem.writeFileString(absolutePath, contents); + }), + ignorePath: (cwd, pattern) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.writeFileString(path.join(cwd, ".gitignore"), `${pattern}\n`); + }), + }, + }); +}); + +describeJj("JjVcsDriver detection", () => { + it.effect( + "resolves the workspace root from a nested directory without spawning a process", + () => { + const calls = { count: 0 }; + const countingProcess = Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.sync(() => { + calls.count += 1; + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const created = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-jj-detect-" }); + const root = yield* fileSystem.realPath(created); + yield* createJjRepo(root); + const nested = path.join(root, "src", "deep"); + yield* fileSystem.makeDirectory(nested, { recursive: true }); + + const driver = yield* JjVcsDriver.JjVcsDriver; + const identity = yield* driver.detectRepository(nested); + + assert.equal(identity?.kind, "jj"); + assert.equal(identity?.rootPath, root); + assert.equal(identity?.metadataPath, path.join(root, ".jj")); + assert.isTrue(yield* driver.isInsideWorkTree(nested)); + assert.equal(calls.count, 0); + }).pipe( + Effect.provide( + JjVcsDriver.layer.pipe( + Layer.provide(countingProcess), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }, + ); + + it.effect("detects a secondary workspace as its own jj root", () => + withRepo(({ driver, fileSystem, path, base, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "a.txt"), "a\n"); + yield* runJj(root, ["commit", "-m", "first"]); + const workspacesDir = path.join(base, "workspaces"); + yield* fileSystem.makeDirectory(workspacesDir, { recursive: true }); + const secondary = path.join(workspacesDir, "thread"); + yield* runJj(root, ["workspace", "add", "--name", "t3-thread", secondary]); + + const identity = yield* driver.detectRepository(secondary); + assert.equal(identity?.rootPath, yield* fileSystem.realPath(secondary)); + + const paths = yield* driver.repoPaths(secondary); + assert.equal(paths.mainWorkspaceRoot, root); + assert.equal(paths.isSecondaryWorkspace, true); + }), + ), + ); +}); + +describeJj("JjVcsDriver reads", () => { + it.effect("keeps the root commit out of a first change's parents", () => + withRepo(({ driver, fileSystem, path, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "a.txt"), "a\n"); + + const change = yield* driver.currentChange(root); + + assert.deepStrictEqual(change.parentCommitIds, []); + assert.equal(change.empty, false); + assert.deepStrictEqual( + change.fileStats.map((file) => file.path), + ["a.txt"], + ); + }), + ), + ); + + it.effect("reports the root commit as no change at all", () => + withRepo(({ driver, root }) => + Effect.gen(function* () { + assert.equal(yield* driver.changeAt(root, "trunk()"), null); + assert.equal(yield* driver.changeAt(root, "root()"), null); + }), + ), + ); + + it.effect("reports a missing revision as no change", () => + withRepo(({ driver, root }) => + Effect.gen(function* () { + assert.equal(yield* driver.changeAt(root, "missing-bookmark-name"), null); + }), + ), + ); + + it.effect("falls back to a local main bookmark when trunk resolves to nothing", () => + withRepo(({ driver, fileSystem, path, root }) => + Effect.gen(function* () { + assert.equal(yield* driver.resolveDefaultBookmark(root), null); + + yield* fileSystem.writeFileString(path.join(root, "a.txt"), "a\n"); + yield* runJj(root, ["commit", "-m", "first"]); + yield* runJj(root, ["bookmark", "create", "main", "-r", "@-"]); + yield* driver.invalidateRepoCaches(root); + + assert.equal(yield* driver.resolveDefaultBookmark(root), "main"); + }), + ), + ); + + it.effect("reads the bookmarked ancestor segment newest first", () => + withRepo(({ driver, fileSystem, path, root }) => + Effect.gen(function* () { + assert.deepStrictEqual(yield* driver.currentSegment(root), []); + + yield* fileSystem.writeFileString(path.join(root, "a.txt"), "a\n"); + yield* runJj(root, ["commit", "-m", "first"]); + yield* runJj(root, ["bookmark", "create", "main", "-r", "@-"]); + yield* fileSystem.writeFileString(path.join(root, "b.txt"), "b\n"); + + const segment = yield* driver.currentSegment(root); + assert.equal(segment.length, 2); + assert.deepStrictEqual(segment[0]?.localBookmarks, []); + assert.deepStrictEqual(segment[1]?.localBookmarks, ["main"]); + }), + ), + ); + + it.effect("drops the colocated git pseudo-remote from the bookmark list", () => + withRepo(({ driver, fileSystem, path, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "a.txt"), "a\n"); + yield* runJj(root, ["commit", "-m", "first"]); + yield* runJj(root, ["bookmark", "create", "main", "-r", "@-"]); + + const bookmarks = yield* driver.listBookmarks(root); + assert.deepStrictEqual( + bookmarks.map((bookmark) => bookmark.remote), + [null], + ); + assert.equal(bookmarks[0]?.name, "main"); + assert.equal(bookmarks[0]?.target?.length, 40); + assert.isTrue((bookmarks[0]?.targetTimestamp ?? 0) > 0); + }), + ), + ); + + it.effect("still decodes bookmarks after a rename leaves a deleted local bookmark", () => + withRepo(({ driver, fileSystem, path, base, root }) => + Effect.gen(function* () { + const remote = path.join(base, "origin.git"); + yield* fileSystem.makeDirectory(remote, { recursive: true }); + yield* runGit(remote, ["init", "--bare", "--quiet"]); + + yield* fileSystem.writeFileString(path.join(root, "a.txt"), "a\n"); + yield* runJj(root, ["commit", "-m", "first"]); + yield* runJj(root, ["bookmark", "create", "main", "-r", "@-"]); + yield* runJj(root, ["git", "remote", "add", "origin", remote]); + yield* runJj(root, ["git", "push", "--bookmark=main"]); + // The rename leaves `main` tracking origin with no local target, and jj's + // `normal_target` renders an error rather than a value for that row. + yield* runJj(root, ["bookmark", "rename", "main", "renamed"]); + + const bookmarks = yield* driver.listBookmarks(root); + assert.deepStrictEqual( + bookmarks.map((bookmark) => ({ name: bookmark.name, remote: bookmark.remote })), + [ + { name: "main", remote: "origin" }, + { name: "renamed", remote: null }, + ], + ); + }), + ), + ); + + it.effect("parses the remote list", () => + withRepo(({ driver, root }) => + Effect.gen(function* () { + yield* runJj(root, ["git", "remote", "add", "origin", "https://example.com/repo.git"]); + + const result = yield* driver.listRemotes(root); + assert.deepStrictEqual( + result.remotes.map((remote) => ({ + name: remote.name, + url: remote.url, + isPrimary: remote.isPrimary, + })), + [{ name: "origin", url: "https://example.com/repo.git", isPrimary: true }], + ); + assert.equal(yield* driver.resolvePrimaryRemoteName(root), "origin"); + }), + ), + ); + + it.effect("counts the commits in a revset", () => + withRepo(({ driver, fileSystem, path, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "a.txt"), "a\n"); + yield* runJj(root, ["commit", "-m", "first"]); + yield* runJj(root, ["commit", "-m", "second"]); + + assert.equal(yield* driver.countRevset(root, "::@ ~ root()"), 3); + assert.equal(yield* driver.countRevset(root, "no-such-bookmark@nowhere"), 0); + }), + ), + ); + + it.effect("lists files relative to the directory that was listed", () => + withRepo(({ driver, fileSystem, path, root }) => + Effect.gen(function* () { + yield* fileSystem.makeDirectory(path.join(root, "src"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(root, "root.txt"), "root\n"); + yield* fileSystem.writeFileString(path.join(root, "src", "b.txt"), "b\n"); + + const result = yield* driver.listWorkspaceFiles(path.join(root, "src")); + + assert.deepStrictEqual(result.paths, ["b.txt"]); + assert.equal(result.truncated, false); + }), + ), + ); + + it.effect("honours the whole git ignore stack when filtering paths", () => + withRepo(({ driver, fileSystem, path, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, ".gitignore"), "*.log\n"); + yield* fileSystem.makeDirectory(path.join(root, "nested"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(root, "nested", ".gitignore"), "*.tmp\n"); + yield* fileSystem.makeDirectory(path.join(root, ".git", "info"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(root, ".git", "info", "exclude"), "secret*\n"); + + const kept = yield* driver.filterIgnoredPaths(root, [ + "keep.ts", + "debug.log", + "nested/scratch.tmp", + "secret.env", + ]); + + assert.deepStrictEqual(kept, ["keep.ts"]); + }), + ), + ); + + it.effect("reports a deleted workspace's root as null, never the server's own directory", () => + withRepo(({ driver, fileSystem, path, base, root }) => + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(root, "a.txt"), "a\n"); + yield* runJj(root, ["commit", "-m", "first"]); + const workspacesDir = path.join(base, "workspaces"); + yield* fileSystem.makeDirectory(workspacesDir, { recursive: true }); + const secondary = path.join(workspacesDir, "gone"); + yield* runJj(root, ["workspace", "add", "--name", "t3-gone", secondary]); + yield* fileSystem.remove(secondary, { recursive: true }); + + const workspaces = yield* driver.listWorkspaces(root); + const gone = workspaces.find((workspace) => workspace.name === "t3-gone"); + + assert.equal(gone?.root, null); + assert.isFalse(workspaces.some((workspace) => workspace.root === process.cwd())); + }), + ), + ); +}); + +describeJj("JjVcsDriver usability", () => { + it.effect("supports checkpoints in a colocated repository", () => + withRepo(({ driver, root }) => + Effect.gen(function* () { + assert.isTrue(yield* driver.checkpointsUsable(root)); + }), + ), + ); + + it.effect("refuses a repository that is not colocated, with an actionable reason", () => + withJjRepo({ prefix: "t3-jj-external-", colocated: false }, ({ driver, root }) => + Effect.gen(function* () { + assert.equal((yield* driver.repoPaths(root)).gitDir, null); + assert.isFalse(yield* driver.checkpointsUsable(root)); + + const error = yield* driver.currentChange(root).pipe(Effect.flip); + assert.equal(error._tag, "VcsUnsupportedOperationError"); + assert.include( + error._tag === "VcsUnsupportedOperationError" ? error.detail : "", + "colocated Jujutsu repositories", + ); + }), + ), + ); +}); + +describeJj("JjVcsDriver list reads", () => { + it.effect("propagates a non-zero exit instead of reporting an empty list", () => { + const failingProcess = Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(input.args.includes("--version") ? 0 : 1), + stdout: input.args.includes("--version") ? "jj 0.42.0\n" : "", + stderr: input.args.includes("--version") + ? "" + : "Error: the operation log is unreadable\n", + stdoutTruncated: false, + stderrTruncated: false, + }), + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const created = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-jj-list-fail-" }); + const root = path.join(yield* fileSystem.realPath(created), "work"); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* createJjRepo(root); + + const driver = yield* JjVcsDriver.JjVcsDriver; + + // An empty list here renders an empty branch picker with `isRepo: true`, and tells + // `removeWorktree` that a healthy workspace is not registered. + assert.equal( + (yield* driver.listBookmarks(root).pipe(Effect.flip))._tag, + "VcsProcessExitError", + ); + assert.equal( + (yield* driver.listWorkspaces(root).pipe(Effect.flip))._tag, + "VcsProcessExitError", + ); + }).pipe( + Effect.provide( + JjVcsDriver.layer.pipe( + Layer.provide(failingProcess), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }); +}); diff --git a/apps/server/src/vcs/JjVcsDriver.ts b/apps/server/src/vcs/JjVcsDriver.ts new file mode 100644 index 000000000000..d5ced2c35d46 --- /dev/null +++ b/apps/server/src/vcs/JjVcsDriver.ts @@ -0,0 +1,854 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +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 Schema from "effect/Schema"; + +import { + type VcsDriverCapabilities, + type VcsError, + VcsProcessExitError, + VcsRepositoryDetectionError, + VcsUnsupportedOperationError, +} from "@t3tools/contracts"; +import { splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import * as JjAvailability from "./JjAvailability.ts"; +import { makeJjCheckpointOps } from "./JjCheckpoints.ts"; +import * as JjProcess from "./JjProcess.ts"; +import * as JjRepo from "./JjRepo.ts"; +import { makeJjReviewDiff } from "./JjReviewDiff.ts"; +import * as VcsDriver from "./VcsDriver.ts"; +import { chunkPathsForCheckIgnore, splitLineSeparatedPaths } from "./VcsPathCodecs.ts"; +import * as VcsProcess from "./VcsProcess.ts"; + +export interface JjChangeFileStat { + /** Repo-relative, as jj templates emit. */ + readonly path: string; + readonly status: "modified" | "added" | "removed" | "copied" | "renamed"; + readonly linesAdded: number; + readonly linesRemoved: number; +} + +export interface JjChange { + readonly commitId: string; + /** + * Real parents only. The decoder drops any parent jj reports as the root commit, so a first + * change in a fresh repository yields `[]` instead of the all-zeros root commit id, which + * `jj restore` accepts (emptying the working copy) and git rejects. + */ + readonly parentCommitIds: ReadonlyArray; + readonly description: string; + readonly empty: boolean; + readonly conflict: boolean; + readonly localBookmarks: ReadonlyArray; + /** Conflict scaffolding rows are filtered out here, once. */ + readonly fileStats: ReadonlyArray; +} + +export interface JjSegmentRow { + readonly commitId: string; + readonly empty: boolean; + readonly localBookmarks: ReadonlyArray; +} + +export interface JjBookmark { + readonly name: string; + /** `null` for a local bookmark; the remote name otherwise. The `git` pseudo-remote is dropped. */ + readonly remote: string | null; + readonly target: string | null; + readonly tracked: boolean; + readonly synced: boolean; + readonly conflict: boolean; + /** Unix seconds of the target commit's committer timestamp, for date-desc ordering. */ + readonly targetTimestamp: number; +} + +export interface JjWorkspace { + readonly name: string; + /** + * Realpath-normalised, or `null` when jj reports no root, which is what `jj workspace list` does + * for a workspace whose directory was deleted. An empty root is never realpath'd: that resolves + * to the server process's own cwd, which would hand a thread or a delete the wrong directory. + */ + readonly root: string | null; +} + +type VcsDriverService = VcsDriver.VcsDriver["Service"]; + +export interface JjVcsDriverShape extends VcsDriverService { + readonly capabilities: VcsDriverCapabilities & { readonly kind: "jj" }; + readonly repoPaths: (cwd: string) => Effect.Effect; + /** Always implemented here: `false` for a repository jj detects but cannot operate on. */ + readonly checkpointsUsable: (cwd: string) => Effect.Effect; + /** Fails with `VcsUnsupportedOperationError` when jj is missing, old, or non-colocated. */ + readonly ensureUsable: ( + operation: string, + cwd: string, + ) => Effect.Effect; + /** `@`. Snapshots the working copy, that is the point. */ + readonly currentChange: (cwd: string) => Effect.Effect; + /** Any revset, `--limit 1`. `null` when it resolves to nothing, and when it resolves to root. */ + readonly changeAt: (cwd: string, revset: string) => Effect.Effect; + /** `heads(::@ & bookmarks())::@`, newest first. Empty when `@` has no bookmarked ancestor. */ + readonly currentSegment: (cwd: string) => Effect.Effect, VcsError>; + readonly listBookmarks: (cwd: string) => Effect.Effect, VcsError>; + readonly listWorkspaces: (cwd: string) => Effect.Effect, VcsError>; + /** Commits in a revset, capped; used for ahead/behind. */ + readonly countRevset: (cwd: string, revset: string) => Effect.Effect; + readonly resolveDefaultBookmark: (cwd: string) => Effect.Effect; + readonly listRemoteNames: (cwd: string) => Effect.Effect, VcsError>; + readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; + /** Drops this repo's cached default bookmark and remote names. */ + readonly invalidateRepoCaches: (cwd: string) => Effect.Effect; +} + +export class JjVcsDriver extends Context.Service()( + "t3/vcs/JjVcsDriver", +) {} + +const REPO_PATHS_CACHE_CAPACITY = 2_048; +const REPO_PATHS_CACHE_TTL = Duration.minutes(10); +const REPO_METADATA_CACHE_CAPACITY = 512; +const REPO_METADATA_CACHE_TTL = Duration.minutes(5); +const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const DEFAULT_BASE_BOOKMARK_CANDIDATES = ["main", "master"] as const; +/** 64 KiB of one-byte rows caps the count near 32k commits, far past anything the UI renders. */ +const COUNT_REVSET_MAX_OUTPUT_BYTES = 64 * 1024; +const CONFLICT_SCAFFOLDING_PREFIX = ".jjconflict-"; +const CONFLICT_SCAFFOLDING_README = "JJ-CONFLICT-README"; +/** jj's colocated pseudo-remote: it mirrors `refs/heads`, so it is never a real remote row. */ +const GIT_PSEUDO_REMOTE = "git"; + +const CHANGE_TEMPLATE = + '"{" ++ "\\"commit_id\\":" ++ json(commit_id) ++' + + ' ",\\"root\\":" ++ json(self.root()) ++' + + ' ",\\"parents\\":" ++ json(parents.map(|p| p.commit_id())) ++' + + ' ",\\"parent_roots\\":" ++ json(parents.map(|p| p.root())) ++' + + ' ",\\"description\\":" ++ json(description) ++' + + ' ",\\"empty\\":" ++ json(empty) ++' + + ' ",\\"conflict\\":" ++ json(conflict) ++' + + ' ",\\"local_bookmarks\\":" ++ json(local_bookmarks.map(|b| b.name())) ++' + + ' ",\\"files\\":[" ++ self.diff().stat().files().map(|f|' + + ' "{\\"path\\":" ++ json(f.path()) ++' + + ' ",\\"status\\":" ++ json(f.status()) ++' + + ' ",\\"added\\":" ++ json(f.lines_added()) ++' + + ' ",\\"removed\\":" ++ json(f.lines_removed()) ++ "}").join(",") ++ "]" ++ "}\\n"'; + +const SEGMENT_TEMPLATE = + '"{\\"commit_id\\":" ++ json(commit_id) ++' + + ' ",\\"empty\\":" ++ json(empty) ++' + + ' ",\\"local_bookmarks\\":" ++ json(local_bookmarks.map(|b| b.name())) ++ "}\\n"'; + +const BOOKMARK_TEMPLATE = + '"{" ++ "\\"name\\":" ++ json(name) ++' + + ' ",\\"remote\\":" ++ json(remote) ++' + + ' ",\\"tracked\\":" ++ json(tracked) ++' + + ' ",\\"synced\\":" ++ json(synced) ++' + + ' ",\\"conflict\\":" ++ json(conflict) ++' + + ' ",\\"present\\":" ++ json(present) ++' + + ' ",\\"target\\":" ++ if(conflict || !present, "null", json(normal_target.commit_id())) ++' + + ' ",\\"timestamp\\":" ++' + + ' if(conflict || !present, "\\"0\\"", json(normal_target.committer().timestamp().utc().format("%s"))) ++' + + ' "}\\n"'; + +const WORKSPACE_TEMPLATE = 'name ++ "\\0" ++ stringify(root) ++ "\\0"'; + +const FILE_LIST_TEMPLATE = 'json(path) ++ "\\n"'; + +const JjChangeRow = Schema.Struct({ + commit_id: Schema.String, + root: Schema.Boolean, + parents: Schema.Array(Schema.String), + parent_roots: Schema.Array(Schema.Boolean), + description: Schema.String, + empty: Schema.Boolean, + conflict: Schema.Boolean, + local_bookmarks: Schema.Array(Schema.String), + files: Schema.Array( + Schema.Struct({ + path: Schema.String, + status: Schema.Literals(["modified", "added", "removed", "copied", "renamed"]), + added: Schema.Number, + removed: Schema.Number, + }), + ), +}); + +const JjSegmentRowSchema = Schema.Struct({ + commit_id: Schema.String, + empty: Schema.Boolean, + local_bookmarks: Schema.Array(Schema.String), +}); + +const JjBookmarkRow = Schema.Struct({ + name: Schema.String, + remote: Schema.NullOr(Schema.String), + tracked: Schema.Boolean, + synced: Schema.Boolean, + conflict: Schema.Boolean, + present: Schema.Boolean, + target: Schema.NullOr(Schema.String), + /** jj's `format("%s")` renders through a template, so this arrives as a JSON string. */ + timestamp: Schema.String, +}); + +const decodeChangeRow = Schema.decodeUnknownEffect(Schema.fromJsonString(JjChangeRow)); +const decodeSegmentRow = Schema.decodeUnknownEffect(Schema.fromJsonString(JjSegmentRowSchema)); +const decodeBookmarkRow = Schema.decodeUnknownEffect(Schema.fromJsonString(JjBookmarkRow)); +const decodePathRow = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String)); + +const decodeFailure = (operation: string, command: string, cwd: string) => + Effect.mapError( + (cause: unknown) => + new VcsProcessExitError({ + operation, + command, + cwd, + exitCode: 0, + detail: `Could not decode ${command} output: ${String(cause)}`, + }), + ); + +/** Freshness stamp for data the driver just read out of the local working copy. */ +const nowFreshness = Effect.fn("JjVcsDriver.nowFreshness")(function* () { + const now = yield* DateTime.now; + return { + source: "live-local" as const, + observedAt: now, + expiresAt: Option.none(), + }; +}); + +function isConflictScaffolding(filePath: string): boolean { + return ( + filePath.startsWith(CONFLICT_SCAFFOLDING_PREFIX) || filePath === CONFLICT_SCAFFOLDING_README + ); +} + +function toJjChange(row: typeof JjChangeRow.Type): JjChange { + return { + commitId: row.commit_id, + parentCommitIds: row.parents.filter((_, index) => row.parent_roots[index] !== true), + description: row.description, + empty: row.empty, + conflict: row.conflict, + localBookmarks: row.local_bookmarks, + fileStats: row.files + .filter((file) => !isConflictScaffolding(file.path)) + .map((file) => ({ + path: file.path, + status: file.status, + linesAdded: file.added, + linesRemoved: file.removed, + })), + }; +} + +function parseRemoteListLine(line: string): { name: string; url: string } | null { + const separatorIndex = line.search(/\s/); + if (separatorIndex <= 0) { + return null; + } + const name = line.slice(0, separatorIndex); + const url = line.slice(separatorIndex + 1).trim(); + return url.length > 0 ? { name, url } : null; +} + +const makeRepoCache = ( + capacity: number, + timeToLive: Duration.Duration, + lookup: (repoRoot: string) => Effect.Effect, +) => + Cache.makeWith(lookup, { + capacity, + timeToLive: Exit.match({ onSuccess: () => timeToLive, onFailure: () => Duration.zero }), + }); + +export const makeVcsDriverShape = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const vcsProcess = yield* VcsProcess.VcsProcess; + const availability = yield* JjAvailability.makeJjAvailability; + + const capabilities = { + kind: "jj", + supportsWorktrees: false, + supportsBookmarks: true, + supportsAtomicSnapshot: true, + supportsPushDefaultRemote: false, + ignoreClassifier: "git-compatible-fallback", + } as const satisfies VcsDriverCapabilities & { readonly kind: "jj" }; + + const repoPathsCache = yield* makeRepoCache( + REPO_PATHS_CACHE_CAPACITY, + REPO_PATHS_CACHE_TTL, + (workspaceRoot) => JjRepo.resolveJjRepoPaths(fileSystem, path, workspaceRoot), + ); + + const markerRoot = (cwd: string) => JjRepo.findVcsMarkerRoot(fileSystem, path, cwd); + + const repoPaths: JjVcsDriverShape["repoPaths"] = Effect.fn("JjVcsDriver.repoPaths")(function* ( + cwd: string, + ) { + const marker = yield* markerRoot(cwd); + if (marker === null || marker.marker !== "jj") { + return yield* Effect.fail( + new VcsRepositoryDetectionError({ + operation: "JjVcsDriver.repoPaths", + cwd, + detail: "No Jujutsu workspace was found at this path.", + }), + ); + } + return yield* Cache.get(repoPathsCache, marker.root); + }); + + const ensureUsable: JjVcsDriverShape["ensureUsable"] = Effect.fn("JjVcsDriver.ensureUsable")( + function* (operation: string, cwd: string) { + const paths = yield* repoPaths(cwd); + const reason = JjAvailability.jjUnsupportedReason({ + availability: yield* availability(paths.workspaceRoot), + colocated: paths.gitDir !== null, + }); + if (reason !== null || paths.gitDir === null) { + return yield* Effect.fail( + new VcsUnsupportedOperationError({ + operation, + kind: "jj", + detail: reason ?? "This Jujutsu workspace has no colocated Git store.", + }), + ); + } + return { ...paths, gitDir: paths.gitDir }; + }, + ); + + const jjLog = ( + operation: string, + cwd: string, + args: ReadonlyArray, + options?: JjProcess.JjCommandOptions, + ) => JjProcess.jjCommand(vcsProcess, operation, cwd, args, options); + + const changeFromRevset = Effect.fn("JjVcsDriver.changeFromRevset")(function* ( + operation: string, + cwd: string, + revset: string, + ignoreWorkingCopy: boolean, + ): Effect.fn.Return { + const result = yield* jjLog( + operation, + cwd, + ["log", "-r", revset, "--no-graph", "--limit", "1", "-T", CHANGE_TEMPLATE], + { + timeoutMs: 20_000, + maxOutputBytes: 4 * 1024 * 1024, + ignoreWorkingCopy, + allowNonZeroExit: true, + }, + ); + + if (result.exitCode !== 0) { + const failureKind = VcsProcess.classifyNonZeroExit("jj", result.stderr); + if (failureKind === "not-found") return null; + return yield* Effect.fail( + new VcsProcessExitError({ + operation, + command: "jj log", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "jj log failed", + failureKind, + }), + ); + } + + const line = splitLineSeparatedPaths(result.stdout, result.stdoutTruncated)[0]; + if (line === undefined) { + return null; + } + + const row = yield* decodeChangeRow(line).pipe(decodeFailure(operation, "jj log", cwd)); + return row.root ? null : toJjChange(row); + }); + + const currentChange: JjVcsDriverShape["currentChange"] = Effect.fn("JjVcsDriver.currentChange")( + function* (cwd: string) { + const operation = "JjVcsDriver.currentChange"; + yield* ensureUsable(operation, cwd); + const change = yield* changeFromRevset(operation, cwd, "@", false); + if (change === null) { + return yield* Effect.fail( + new VcsProcessExitError({ + operation, + command: "jj log", + cwd, + exitCode: 0, + detail: "jj reported no working-copy change for this workspace.", + }), + ); + } + return change; + }, + ); + + const changeAt: JjVcsDriverShape["changeAt"] = Effect.fn("JjVcsDriver.changeAt")(function* ( + cwd: string, + revset: string, + ) { + const operation = "JjVcsDriver.changeAt"; + yield* ensureUsable(operation, cwd); + return yield* changeFromRevset(operation, cwd, revset, true); + }); + + const currentSegment: JjVcsDriverShape["currentSegment"] = Effect.fn( + "JjVcsDriver.currentSegment", + )(function* (cwd: string) { + const operation = "JjVcsDriver.currentSegment"; + yield* ensureUsable(operation, cwd); + const result = yield* jjLog( + operation, + cwd, + ["log", "-r", "heads(::@ & bookmarks())::@", "--no-graph", "-T", SEGMENT_TEMPLATE], + { + allowNonZeroExit: true, + ignoreWorkingCopy: true, + timeoutMs: 20_000, + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }, + ); + + if (result.exitCode !== 0) { + return []; + } + + const rows = yield* Effect.forEach( + splitLineSeparatedPaths(result.stdout, result.stdoutTruncated), + (line) => decodeSegmentRow(line).pipe(decodeFailure(operation, "jj log", cwd)), + ); + + return rows.map((row) => ({ + commitId: row.commit_id, + empty: row.empty, + localBookmarks: row.local_bookmarks, + })); + }); + + const listBookmarks: JjVcsDriverShape["listBookmarks"] = Effect.fn("JjVcsDriver.listBookmarks")( + function* (cwd: string) { + const operation = "JjVcsDriver.listBookmarks"; + yield* ensureUsable(operation, cwd); + const result = yield* jjLog( + operation, + cwd, + ["bookmark", "list", "--all-remotes", "-T", BOOKMARK_TEMPLATE], + { + ignoreWorkingCopy: true, + timeoutMs: 10_000, + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }, + ); + + const rows = yield* Effect.forEach( + splitLineSeparatedPaths(result.stdout, result.stdoutTruncated), + (line) => decodeBookmarkRow(line).pipe(decodeFailure(operation, "jj bookmark list", cwd)), + ); + + return rows + .filter((row) => row.present && row.remote !== GIT_PSEUDO_REMOTE) + .map((row) => { + const timestamp = Number.parseInt(row.timestamp, 10); + return { + name: row.name, + remote: row.remote, + target: row.target, + tracked: row.tracked, + synced: row.synced, + conflict: row.conflict, + targetTimestamp: Number.isNaN(timestamp) ? 0 : timestamp, + } satisfies JjBookmark; + }); + }, + ); + + const listWorkspaces: JjVcsDriverShape["listWorkspaces"] = Effect.fn( + "JjVcsDriver.listWorkspaces", + )(function* (cwd: string) { + const operation = "JjVcsDriver.listWorkspaces"; + yield* ensureUsable(operation, cwd); + const result = yield* jjLog(operation, cwd, ["workspace", "list", "-T", WORKSPACE_TEMPLATE], { + ignoreWorkingCopy: true, + timeoutMs: 10_000, + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }); + + const fields = result.stdout.split("\0"); + if (result.stdoutTruncated || fields.pop() !== "" || fields.length % 2 !== 0) { + return yield* new VcsProcessExitError({ + operation, + command: "jj workspace list", + cwd, + exitCode: 0, + detail: "Workspace listing was incomplete.", + }); + } + const rows: JjWorkspace[] = []; + for (let index = 0; index < fields.length; index += 2) { + const name = fields[index]; + const root = fields[index + 1]; + if (name === undefined || root === undefined || name === "") { + return yield* new VcsProcessExitError({ + operation, + command: "jj workspace list", + cwd, + exitCode: 0, + detail: "Workspace listing was malformed.", + }); + } + // jj 0.42 renders template errors inline when a registered directory is missing. + rows.push({ + name, + root: + root === "" || root.startsWith(" + Effect.gen(function* () { + const root = + row.root === null + ? null + : yield* fileSystem.realPath(row.root).pipe(Effect.orElseSucceed(() => row.root)); + return { name: row.name, root } satisfies JjWorkspace; + }), + ); + }); + + const countRevset: JjVcsDriverShape["countRevset"] = Effect.fn("JjVcsDriver.countRevset")( + function* (cwd: string, revset: string) { + const operation = "JjVcsDriver.countRevset"; + yield* ensureUsable(operation, cwd); + const result = yield* jjLog( + operation, + cwd, + ["log", "-r", revset, "--no-graph", "-T", '"1\\n"'], + { + allowNonZeroExit: true, + ignoreWorkingCopy: true, + timeoutMs: 20_000, + maxOutputBytes: COUNT_REVSET_MAX_OUTPUT_BYTES, + outputMode: "truncate", + }, + ); + + return result.exitCode === 0 + ? splitLineSeparatedPaths(result.stdout, result.stdoutTruncated).length + : 0; + }, + ); + + const listRemotes: VcsDriver.VcsDriver["Service"]["listRemotes"] = Effect.fn( + "JjVcsDriver.listRemotes", + )(function* (cwd: string) { + const operation = "JjVcsDriver.listRemotes"; + yield* ensureUsable(operation, cwd); + const result = yield* jjLog(operation, cwd, ["git", "remote", "list"], { + allowNonZeroExit: true, + ignoreWorkingCopy: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }); + + const remotes = + result.exitCode === 0 + ? splitLineSeparatedPaths(result.stdout, result.stdoutTruncated).flatMap((line) => { + const remote = parseRemoteListLine(line); + return remote === null + ? [] + : [ + { + name: remote.name, + url: remote.url, + pushUrl: Option.none(), + isPrimary: remote.name === "origin", + }, + ]; + }) + : []; + + return { remotes, freshness: yield* nowFreshness() }; + }); + + const remoteNamesCache = yield* makeRepoCache( + REPO_METADATA_CACHE_CAPACITY, + REPO_METADATA_CACHE_TTL, + (mainWorkspaceRoot) => + listRemotes(mainWorkspaceRoot).pipe( + Effect.map((result) => result.remotes.map((remote) => remote.name)), + ), + ); + + const listRemoteNames: JjVcsDriverShape["listRemoteNames"] = Effect.fn( + "JjVcsDriver.listRemoteNames", + )(function* (cwd: string) { + const paths = yield* ensureUsable("JjVcsDriver.listRemoteNames", cwd); + return yield* Cache.get(remoteNamesCache, paths.mainWorkspaceRoot); + }); + + const resolvePrimaryRemoteName: JjVcsDriverShape["resolvePrimaryRemoteName"] = Effect.fn( + "JjVcsDriver.resolvePrimaryRemoteName", + )(function* (cwd: string) { + const names = yield* listRemoteNames(cwd); + return names.includes("origin") ? "origin" : (names[0] ?? null); + }); + + const resolveDefaultBookmarkUncached = Effect.fn("JjVcsDriver.resolveDefaultBookmarkUncached")( + function* (cwd: string): Effect.fn.Return { + const bookmarks = yield* listBookmarks(cwd); + const trunkChange = yield* changeAt(cwd, "trunk()"); + + if (trunkChange !== null) { + const primaryRemote = yield* resolvePrimaryRemoteName(cwd); + const remoteMatch = bookmarks.find( + (bookmark) => + bookmark.target === trunkChange.commitId && + bookmark.remote === primaryRemote && + !bookmark.conflict, + ); + if (remoteMatch) { + return remoteMatch.name; + } + + const localMatches = bookmarks + .filter( + (bookmark) => bookmark.target === trunkChange.commitId && bookmark.remote === null, + ) + .map((bookmark) => bookmark.name) + .sort(); + if (localMatches[0] !== undefined) { + return localMatches[0]; + } + } + + const localNames = new Set( + bookmarks.filter((bookmark) => bookmark.remote === null).map((bookmark) => bookmark.name), + ); + return ( + DEFAULT_BASE_BOOKMARK_CANDIDATES.find((candidate) => localNames.has(candidate)) ?? null + ); + }, + ); + + const defaultBookmarkCache = yield* makeRepoCache( + REPO_METADATA_CACHE_CAPACITY, + REPO_METADATA_CACHE_TTL, + resolveDefaultBookmarkUncached, + ); + + const resolveDefaultBookmark: JjVcsDriverShape["resolveDefaultBookmark"] = Effect.fn( + "JjVcsDriver.resolveDefaultBookmark", + )(function* (cwd: string) { + const paths = yield* ensureUsable("JjVcsDriver.resolveDefaultBookmark", cwd); + return yield* Cache.get(defaultBookmarkCache, paths.mainWorkspaceRoot); + }); + + const invalidateRepoCaches: JjVcsDriverShape["invalidateRepoCaches"] = Effect.fn( + "JjVcsDriver.invalidateRepoCaches", + )(function* (cwd: string) { + const marker = yield* markerRoot(cwd); + if (marker === null || marker.marker !== "jj") { + return; + } + const paths = yield* Cache.get(repoPathsCache, marker.root).pipe( + Effect.orElseSucceed(() => null), + ); + if (paths === null) { + return; + } + yield* Cache.invalidate(remoteNamesCache, paths.mainWorkspaceRoot); + yield* Cache.invalidate(defaultBookmarkCache, paths.mainWorkspaceRoot); + }); + + const isInsideWorkTree: VcsDriver.VcsDriver["Service"]["isInsideWorkTree"] = (cwd) => + markerRoot(cwd).pipe(Effect.map((marker) => marker?.marker === "jj")); + + const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn( + "JjVcsDriver.detectRepository", + )(function* (cwd: string) { + const marker = yield* markerRoot(cwd); + if (marker === null || marker.marker !== "jj") { + return null; + } + return { + kind: "jj" as const, + rootPath: marker.root, + metadataPath: path.join(marker.root, ".jj"), + freshness: yield* nowFreshness(), + }; + }); + + const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) => + jjLog(input.operation, input.cwd, input.args, input); + + const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = (input) => + jjLog("JjVcsDriver.initRepository", input.cwd, ["git", "init", "--colocate"], { + timeoutMs: 10_000, + maxOutputBytes: 64 * 1024, + }).pipe(Effect.asVoid); + + const listWorkspaceFiles: VcsDriver.VcsDriver["Service"]["listWorkspaceFiles"] = Effect.fn( + "JjVcsDriver.listWorkspaceFiles", + )(function* (cwd: string) { + const operation = "JjVcsDriver.listWorkspaceFiles"; + const paths = yield* ensureUsable(operation, cwd); + const result = yield* jjLog(operation, cwd, ["file", "list", ".", "-T", FILE_LIST_TEMPLATE], { + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + outputMode: "truncate", + }); + + // jj templates emit repo-relative paths; `git ls-files`, and every caller, expects them + // relative to the cwd that was listed. + const resolvedCwd = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); + const relativeCwd = path.relative(paths.workspaceRoot, resolvedCwd).split(path.sep).join("/"); + const prefix = relativeCwd.length === 0 ? "" : `${relativeCwd}/`; + + const repoRelativePaths = yield* Effect.forEach( + splitLineSeparatedPaths(result.stdout, result.stdoutTruncated), + (line) => decodePathRow(line).pipe(decodeFailure(operation, "jj file list", cwd)), + ); + + return { + paths: repoRelativePaths.map((repoRelativePath) => + repoRelativePath.startsWith(prefix) + ? repoRelativePath.slice(prefix.length) + : repoRelativePath, + ), + truncated: result.stdoutTruncated, + freshness: yield* nowFreshness(), + }; + }); + + const filterIgnoredPaths: VcsDriver.VcsDriver["Service"]["filterIgnoredPaths"] = Effect.fn( + "JjVcsDriver.filterIgnoredPaths", + )(function* (cwd: string, relativePaths: ReadonlyArray) { + if (relativePaths.length === 0) { + return relativePaths; + } + + const operation = "JjVcsDriver.filterIgnoredPaths"; + // Deliberately not `ensureUsable`: nothing here spawns jj, and leaving the paths unfiltered in + // a repository T3 cannot operate on is safer than failing the caller's whole listing. + const paths = yield* repoPaths(cwd); + if (paths.gitDir === null) { + return relativePaths; + } + + const ignoredPaths = new Set(); + for (const chunk of chunkPathsForCheckIgnore(relativePaths)) { + const result = yield* JjProcess.colocatedGitCommand( + vcsProcess, + operation, + { gitDir: paths.gitDir, workTree: paths.workspaceRoot, cwd }, + ["check-ignore", "--no-index", "-z", "--stdin"], + { + stdin: `${chunk.join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + + if (result.exitCode !== 0 && result.exitCode !== 1) { + return yield* Effect.fail( + new VcsProcessExitError({ + operation, + command: "git check-ignore", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git check-ignore failed", + }), + ); + } + + for (const ignoredPath of splitNullSeparatedGitStdoutPaths(result)) { + ignoredPaths.add(ignoredPath); + } + } + + return ignoredPaths.size === 0 + ? relativePaths + : relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); + }); + + const checkpointsUsable: JjVcsDriverShape["checkpointsUsable"] = (cwd) => + ensureUsable("JjVcsDriver.checkpointsUsable", cwd).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + + const ensureGitDir = (operation: string, cwd: string) => + ensureUsable(operation, cwd).pipe(Effect.map((paths) => paths.gitDir)); + + const reviewDiff = makeJjReviewDiff({ + process: vcsProcess, + ensureGitDir, + currentChange, + changeAt, + listRemoteNames, + resolveDefaultBookmark, + }); + + return { + capabilities, + execute, + agentContextHint: + "This workspace uses Jujutsu (jj). Use jj for version-control operations; @ is the working-copy revision, and bookmarks name lines of work. Secondary workspaces may have no .git directory. T3 snapshots new files up to 10 MiB; larger new files are outside checkpoints until tracked.", + checkpoints: makeJjCheckpointOps({ + process: vcsProcess, + ensureGitDir, + currentChange, + fileSystem, + path, + }), + checkpointsUsable, + detectRepository, + isInsideWorkTree, + listWorkspaceFiles, + listRemotes, + filterIgnoredPaths, + initRepository, + getDiffPreview: reviewDiff.getDiffPreview, + getDiffFileContents: reviewDiff.getDiffFileContents, + repoPaths, + ensureUsable, + currentChange, + changeAt, + currentSegment, + listBookmarks, + listWorkspaces, + countRevset, + resolveDefaultBookmark, + listRemoteNames, + resolvePrimaryRemoteName, + invalidateRepoCaches, + } satisfies JjVcsDriverShape; +}); + +export const makeJjVcsDriver = Effect.map(makeVcsDriverShape, (shape) => JjVcsDriver.of(shape)); + +export const makeVcsDriver = Effect.map(makeVcsDriverShape, (shape) => + VcsDriver.VcsDriver.of(shape), +); + +export const layer = Layer.effect(JjVcsDriver, makeJjVcsDriver); +export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver); diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index a8680c9a54be..d8ec751917bc 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -7,6 +7,8 @@ import type { VcsInitInput, VcsListRemotesResult, VcsListWorkspaceFilesResult, + ReviewDiffFileContentsInput, + ReviewDiffFileContentsResult, ReviewDiffPreviewInput, ReviewDiffPreviewResult, VcsRepositoryIdentity, @@ -40,7 +42,9 @@ export interface VcsDeleteCheckpointRefsInput { } export interface VcsCheckpointOps { - readonly captureCheckpoint: (input: VcsCaptureCheckpointInput) => Effect.Effect; + readonly captureCheckpoint: ( + input: VcsCaptureCheckpointInput, + ) => Effect.Effect; readonly hasCheckpointRef: ( input: Omit, ) => Effect.Effect; @@ -53,10 +57,15 @@ export interface VcsCheckpointOps { ) => Effect.Effect; } +export interface CheckpointCaptureResult { + readonly exclusions: ReadonlyArray; +} + export class VcsDriver extends Context.Service< VcsDriver, { readonly capabilities: VcsDriverCapabilities; + readonly agentContextHint?: string; readonly execute: ( input: Omit, ) => Effect.Effect; @@ -77,5 +86,18 @@ export class VcsDriver extends Context.Service< readonly getDiffPreview?: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; + /** + * Expand unchanged context in a review diff. Optional for the same reason as `getDiffPreview`: + * the Git driver reaches its implementation through the legacy `GitVcsDriver` service instead. + */ + readonly getDiffFileContents?: ( + input: ReviewDiffFileContentsInput, + ) => Effect.Effect; + /** + * Whether `checkpoints` can actually run against THIS repository, as opposed to whether this + * driver implements checkpointing at all. A driver that is always usable once detected omits + * it; absent means yes. Never fails: a probe error reads as `false`. + */ + readonly checkpointsUsable?: (cwd: string) => Effect.Effect; } >()("t3/vcs/VcsDriver") {} diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 0ad2450eb192..cd81b1318f7b 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -1,7 +1,9 @@ import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as VcsProcess from "./VcsProcess.ts"; @@ -43,6 +45,44 @@ describe("VcsDriverRegistry", () => { }).pipe(Effect.provide(layer)); }); + it.effect("auto-detects a colocated workspace as jj, ahead of git, without a subprocess", () => { + let processCalls = 0; + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide( + Layer.mock(VcsProjectConfig.VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.sync(() => { + processCalls += 1; + return processOutput(""); + }), + }), + ), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const created = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-jj-registry-" }); + const root = yield* fileSystem.realPath(created); + yield* fileSystem.makeDirectory(path.join(root, ".jj", "repo"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(root, ".git"), { recursive: true }); + + assert.strictEqual((yield* registry.get("jj")).capabilities.kind, "jj"); + + const detected = yield* registry.detect({ cwd: root }); + assert.strictEqual(detected?.kind, "jj"); + assert.strictEqual(detected?.repository.rootPath, root); + assert.strictEqual(processCalls, 0); + }).pipe(Effect.provide(layer)); + }); + it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { const calls: VcsProcess.VcsProcessInput[] = []; const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index f40e1dadea3e..b5fef731524d 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; import { VcsUnsupportedOperationError } from "@t3tools/contracts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as JjVcsDriver from "./JjVcsDriver.ts"; import * as VcsProjectConfig from "./VcsProjectConfig.ts"; import * as VcsDriver from "./VcsDriver.ts"; @@ -33,6 +34,7 @@ export class VcsDriverRegistry extends Context.Service< input: VcsDriverResolveInput, ) => Effect.Effect; readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect; + readonly invalidate: (cwd: string) => Effect.Effect; } >()("t3/vcs/VcsDriverRegistry") {} @@ -63,8 +65,10 @@ function parseDetectionCacheKey(key: string): { export const make = Effect.gen(function* () { const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; const git = yield* GitVcsDriver.makeVcsDriver; + const jj = yield* JjVcsDriver.makeVcsDriver; const drivers: Partial> = { git, + jj, }; const get: VcsDriverRegistry["Service"]["get"] = (kind) => { @@ -108,6 +112,11 @@ export const make = Effect.gen(function* () { return yield* detectWithDriver(requestedKind, driver, input.cwd); } + // jj is probed first because a colocated repo answers yes to both probes, and probing git first would write git worktrees into a jj repo. + const jjDetected = yield* detectWithDriver("jj", jj, input.cwd); + if (jjDetected) { + return jjDetected; + } return yield* detectWithDriver("git", git, input.cwd); }); @@ -152,6 +161,12 @@ export const make = Effect.gen(function* () { get, detect, resolve, + invalidate: (cwd) => + Effect.all([ + Cache.invalidate(detectionCache, detectionCacheKey({ cwd, requestedKind: "auto" })), + Cache.invalidate(detectionCache, detectionCacheKey({ cwd, requestedKind: "git" })), + Cache.invalidate(detectionCache, detectionCacheKey({ cwd, requestedKind: "jj" })), + ]).pipe(Effect.asVoid), }); }); diff --git a/apps/server/src/vcs/VcsPathCodecs.ts b/apps/server/src/vcs/VcsPathCodecs.ts new file mode 100644 index 000000000000..b182db85aada --- /dev/null +++ b/apps/server/src/vcs/VcsPathCodecs.ts @@ -0,0 +1,45 @@ +/** + * `git check-ignore --stdin` reads NUL-separated paths; chunking keeps a large workspace listing + * from overflowing the pipe buffer while the child is still writing its answer. + */ +const CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; + +/** Splits newline-framed process output, such as one JSON record per line. A truncated read ends + * mid-record, so the last frame is dropped rather than parsed. */ +export function splitLineSeparatedPaths(input: string, truncated: boolean): string[] { + const frames = input.split(/\r?\n/g); + if (truncated && frames[frames.length - 1]?.length) { + frames.pop(); + } + return frames.map((line) => line.trim()).filter((line) => line.length > 0); +} + +export function chunkPathsForCheckIgnore(relativePaths: ReadonlyArray): string[][] { + const chunks: string[][] = []; + let chunk: string[] = []; + let chunkBytes = 0; + + for (const relativePath of relativePaths) { + const relativePathBytes = Buffer.byteLength(relativePath) + 1; + if (chunk.length > 0 && chunkBytes + relativePathBytes > CHECK_IGNORE_MAX_STDIN_BYTES) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + + chunk.push(relativePath); + chunkBytes += relativePathBytes; + + if (chunkBytes >= CHECK_IGNORE_MAX_STDIN_BYTES) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + } + + if (chunk.length > 0) { + chunks.push(chunk); + } + + return chunks; +} diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 169383d2cbf8..dc7f66a7c90e 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -126,50 +126,57 @@ describe("VcsProcess.run", () => { }), ); - it.effect.each([ - { - name: "recovers after two lock failures", - failures: 2, - attempts: 3, - transient: true, - capture: true, - streaming: false, - }, - { - name: "bounds persistent lock failures", - failures: Infinity, - attempts: 3, - transient: true, - capture: true, - streaming: false, - }, - { - name: "does not retry unknown exits", - failures: Infinity, - attempts: 1, - transient: false, - capture: true, - streaming: false, - }, - { - name: "leaves other operations unchanged", - failures: Infinity, - attempts: 1, - transient: true, - capture: false, - streaming: false, - }, - { - name: "does not replay stdout callbacks", - failures: Infinity, - attempts: 1, - transient: true, - capture: true, - streaming: true, - }, - ])( - "checkpoint command retry $name", - ({ failures, attempts: expectedAttempts, transient, capture, streaming }) => + it.effect.each( + [ + { + name: "recovers after two lock failures", + failures: 2, + attempts: 3, + transient: true, + capture: true, + streaming: false, + }, + { + name: "bounds persistent lock failures", + failures: Infinity, + attempts: 3, + transient: true, + capture: true, + streaming: false, + }, + { + name: "does not retry unknown exits", + failures: Infinity, + attempts: 1, + transient: false, + capture: true, + streaming: false, + }, + { + name: "leaves other operations unchanged", + failures: Infinity, + attempts: 1, + transient: true, + capture: false, + streaming: false, + }, + { + name: "does not replay stdout callbacks", + failures: Infinity, + attempts: 1, + transient: true, + capture: true, + streaming: true, + }, + ].flatMap((testCase) => + (testCase.capture + ? [VcsProcess.CHECKPOINT_CAPTURE_OPERATION, "JjVcsDriver.checkpoints.captureCheckpoint"] + : [baseInput.operation] + ).map((operation) => ({ ...testCase, operation })), + ), + )( + "checkpoint command retry $name ($operation)", + ({ failures, attempts: expectedAttempts, transient, operation, streaming }) => Effect.gen(function* () { let attempts = 0; const service = yield* VcsProcess.make.pipe( @@ -198,7 +205,7 @@ describe("VcsProcess.run", () => { const fiber = yield* service .run({ ...baseInput, - operation: capture ? VcsProcess.CHECKPOINT_CAPTURE_OPERATION : baseInput.operation, + operation, ...(streaming ? { onStdoutChunk: () => {} } : {}), }) .pipe(Effect.exit, Effect.forkScoped); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 25f3a23c368a..7bc2c14c8d97 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -62,7 +62,7 @@ const GITHUB_PROCESS_CONCURRENCY = 4; export const CHECKPOINT_CAPTURE_OPERATION = "GitVcsDriver.checkpoints.captureCheckpoint"; -const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { +export const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); if ( @@ -100,7 +100,11 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai normalized.includes("404"))) || (command === "az" && normalized.includes("pull request") && - (normalized.includes("not found") || normalized.includes("does not exist"))) + (normalized.includes("not found") || normalized.includes("does not exist"))) || + (command === "jj" && + (normalized.includes("there is no jj repo in") || + normalized.includes("doesn't exist") || + normalized.includes("no such bookmark"))) ) { return "not-found"; } @@ -206,7 +210,8 @@ export const make = Effect.gen(function* () { const bounded = vcsProcesses.withPermits(1)(runUnbounded(input)); if ( input.command === "git" && - input.operation === CHECKPOINT_CAPTURE_OPERATION && + (input.operation === CHECKPOINT_CAPTURE_OPERATION || + input.operation === "JjVcsDriver.checkpoints.captureCheckpoint") && input.onStdoutChunk === undefined ) { // Retry the failed command, retaining the private index/tree and recovery's outer deadline. diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts index 0a28f9c9b2ce..98d0f3b2cb7e 100644 --- a/apps/server/src/vcs/VcsProvisioningService.test.ts +++ b/apps/server/src/vcs/VcsProvisioningService.test.ts @@ -65,6 +65,10 @@ it.effect("routes repository initialization through an explicit VCS driver kind" Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), + invalidate: (cwd) => + Effect.sync(() => { + calls.push(`invalidate:${cwd}`); + }), }), ), ); @@ -73,7 +77,7 @@ it.effect("routes repository initialization through an explicit VCS driver kind" const provisioning = yield* VcsProvisioningService.VcsProvisioningService; yield* provisioning.initRepository({ cwd: "/repo", kind: "git" }); - assert.deepStrictEqual(calls, ["git:/repo"]); + assert.deepStrictEqual(calls, ["git:/repo", "invalidate:/repo"]); }).pipe(Effect.provide(testLayer)); }); @@ -84,6 +88,10 @@ it.effect("defaults repository initialization to Git until callers choose a VCS Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), + invalidate: (cwd) => + Effect.sync(() => { + calls.push(`invalidate:${cwd}`); + }), }), ), ); @@ -92,6 +100,6 @@ it.effect("defaults repository initialization to Git until callers choose a VCS const provisioning = yield* VcsProvisioningService.VcsProvisioningService; yield* provisioning.initRepository({ cwd: "/repo" }); - assert.deepStrictEqual(calls, ["default:/repo"]); + assert.deepStrictEqual(calls, ["default:/repo", "invalidate:/repo"]); }).pipe(Effect.provide(testLayer)); }); diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index e58099b615db..234c06d11014 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -44,7 +44,8 @@ export const make = Effect.gen(function* () { )(function* (input) { const kind = yield* resolveRequestedKind(input.kind); const driver = yield* registry.get(kind); - return yield* driver.initRepository(input); + yield* driver.initRepository(input); + yield* registry.invalidate(input.cwd); }); return VcsProvisioningService.of({ diff --git a/apps/server/src/vcs/WorkspaceFileSafety.ts b/apps/server/src/vcs/WorkspaceFileSafety.ts new file mode 100644 index 000000000000..683c25fa2b23 --- /dev/null +++ b/apps/server/src/vcs/WorkspaceFileSafety.ts @@ -0,0 +1,63 @@ +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; +import type * as GitVcsDriver from "./GitVcsDriver.ts"; + +// A private index makes this work for secondary jj workspaces without consulting the main Git index. +export const inspectWorkspaceFiles = Effect.fn("WorkspaceFileSafety.inspect")(function* (input: { + readonly cwd: string; + readonly repositoryCwd: string; + readonly revision: string; + readonly includeIgnored?: boolean; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly execute: GitVcsDriver.GitVcsDriver["Service"]["execute"]; +}) { + return yield* Effect.scoped( + Effect.gen(function* () { + const directory = yield* input.fileSystem.makeTempDirectoryScoped({ + prefix: "t3-workspace-index-", + }); + const env = { GIT_INDEX_FILE: input.path.join(directory, "index") }; + const run = (args: ReadonlyArray) => + input.execute({ + operation: "WorkspaceFileSafety.inspect", + cwd: input.repositoryCwd, + args: ["--work-tree", input.cwd, ...args], + env, + maxOutputBytes: 64 * 1024, + }); + yield* run(["read-tree", input.revision]); + const other = yield* run(["ls-files", "--others", "--exclude-standard", "--directory", "-z"]); + const ignored = + input.includeIgnored === false + ? null + : yield* run([ + "ls-files", + "--others", + "--ignored", + "--exclude-standard", + "--directory", + "-z", + ]); + const isMetadata = (entry: string): boolean => entry === ".jj/" || entry.startsWith(".jj/"); + const exclusions = other.stdout + .split("\0") + .filter((entry) => entry !== "" && !isMetadata(entry)); + const truncated = other.stdoutTruncated; + return { + exclusions, + truncated, + hasUnpreservedFiles: + truncated || + ignored?.stdoutTruncated === true || + exclusions.length > 0 || + (ignored?.stdout ?? "") + .split("\0") + .some( + (entry) => entry !== "" && !isMetadata(entry) && !/(^|\/)node_modules\/$/.test(entry), + ), + }; + }), + ); +}); diff --git a/apps/server/src/vcs/WorkspaceOwnership.ts b/apps/server/src/vcs/WorkspaceOwnership.ts new file mode 100644 index 000000000000..88c6d9761ffa --- /dev/null +++ b/apps/server/src/vcs/WorkspaceOwnership.ts @@ -0,0 +1,104 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; +import { GitCommandError } from "@t3tools/contracts"; +import type * as GitVcsDriver from "./GitVcsDriver.ts"; +import { findVcsMarkerRoot, resolveJjRepoPaths } from "./JjRepo.ts"; + +export const WorkspaceKind = Schema.Literals(["git", "jj"]); +export type WorkspaceKind = typeof WorkspaceKind.Type; +const decodeKind = Schema.decodeUnknownEffect(WorkspaceKind); + +export function makeWorkspaceOwnership(deps: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly execute: GitVcsDriver.GitVcsDriver["Service"]["execute"]; +}) { + const { fileSystem, path, execute } = deps; + const repositoryCwd = Effect.fn("WorkspaceOwnership.repositoryCwd")(function* (cwd: string) { + const marker = yield* findVcsMarkerRoot(fileSystem, path, cwd); + return marker?.marker === "jj" + ? (yield* resolveJjRepoPaths(fileSystem, path, marker.root)).mainWorkspaceRoot + : cwd; + }); + const normalize = Effect.fn("WorkspaceOwnership.normalize")(function* (value: string) { + const absolute = path.resolve(value); + let current = path.dirname(absolute); + const suffix = [path.basename(absolute)]; + for (;;) { + const canonical = yield* fileSystem.realPath(current).pipe(Effect.option); + if (Option.isSome(canonical)) return path.join(canonical.value, ...suffix); + const parent = path.dirname(current); + if (parent === current) return absolute; + suffix.unshift(path.basename(current)); + current = parent; + } + }); + const location = Effect.fn("WorkspaceOwnership.location")(function* (input: { + readonly cwd: string; + readonly path: string; + }) { + const cwd = yield* repositoryCwd(input.cwd); + const common = yield* execute({ + operation: "WorkspaceOwnership.commonDir", + cwd, + args: ["rev-parse", "--path-format=absolute", "--git-common-dir"], + }); + const normalized = yield* normalize(input.path); + const digest = NodeCrypto.createHash("sha256").update(normalized).digest("hex"); + return { + cwd, + config: path.join(common.stdout.trim(), "config"), + key: `t3.workspace-${digest}.kind`, + }; + }); + const read = Effect.fn("WorkspaceOwnership.read")(function* (input: { + readonly cwd: string; + readonly path: string; + }) { + const target = yield* location(input); + const result = yield* execute({ + operation: "WorkspaceOwnership.read", + cwd: target.cwd, + args: ["config", "--file", target.config, "--get", target.key], + allowNonZeroExit: true, + }); + if (result.exitCode === 1) return null; + if (result.exitCode !== 0) + return yield* new GitCommandError({ + operation: "WorkspaceOwnership.read", + command: "git config", + cwd: input.cwd, + detail: result.stderr || "Could not read workspace ownership.", + }); + return yield* decodeKind(result.stdout.trim()).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "WorkspaceOwnership.read", + command: "git config", + cwd: input.cwd, + detail: "Workspace ownership is invalid.", + cause, + }), + ), + ); + }); + const remember = Effect.fn("WorkspaceOwnership.remember")(function* (input: { + readonly cwd: string; + readonly path: string; + readonly kind: WorkspaceKind; + }) { + const target = yield* location(input); + yield* execute({ + operation: "WorkspaceOwnership.remember", + cwd: target.cwd, + args: ["config", "--file", target.config, "--replace-all", target.key, input.kind], + }); + }); + return { read, remember, normalize, repositoryCwd }; +} diff --git a/apps/server/src/vcs/testing/JjTestSupport.ts b/apps/server/src/vcs/testing/JjTestSupport.ts new file mode 100644 index 000000000000..b9d6a3327014 --- /dev/null +++ b/apps/server/src/vcs/testing/JjTestSupport.ts @@ -0,0 +1,199 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as SourceControlProvider from "../../sourceControl/SourceControlProvider.ts"; +import * as SourceControlProviderRegistry from "../../sourceControl/SourceControlProviderRegistry.ts"; +import { isJjVersionSupported, parseJjVersion } from "../JjAvailability.ts"; +import * as JjVcsDriver from "../JjVcsDriver.ts"; +import * as VcsProcess from "../VcsProcess.ts"; + +const execFileAsync = NodeUtil.promisify(NodeChildProcess.execFile); + +const probe = NodeChildProcess.spawnSync("jj", ["--version"], { encoding: "utf8" }); +const probedVersion = probe.status === 0 ? parseJjVersion(probe.stdout) : null; + +export const JJ_VERSION: string | null = probedVersion; +export const JJ_AVAILABLE: boolean = probedVersion !== null && isJjVersionSupported(probedVersion); + +/** + * Scoped once for this worker so neither the fixtures nor the driver's own `jj` spawns can read the + * developer's jj config, and so every test commit carries an identity (jj allows an empty one, but + * such commits cannot be pushed and the failure surfaces far from the cause). + */ +if (JJ_AVAILABLE) { + const configDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-jj-config-")); + const configPath = NodePath.join(configDir, "config.toml"); + NodeFS.writeFileSync(configPath, ""); + process.env.JJ_CONFIG = configPath; + process.env.JJ_USER = "T3 Code Test"; + process.env.JJ_EMAIL = "t3code-test@example.com"; +} + +/** `describe` that skips the whole block when jj is absent or below the supported floor. */ +export const describeJj = describe.skipIf(!JJ_AVAILABLE); + +export class JjTestCommandError extends Schema.TaggedError()( + "JjTestCommandError", + { + command: Schema.String, + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `jj ${this.command} failed in ${this.cwd}`; + } +} + +/** Runs jj in `cwd` for test fixtures. Fails, never defects. */ +export const runJj = ( + cwd: string, + args: ReadonlyArray, +): Effect.Effect => + Effect.tryPromise({ + try: () => execFileAsync("jj", ["--no-pager", "--color=never", ...args], { cwd }), + catch: (cause) => new JjTestCommandError({ command: args.join(" "), cwd, cause }), + }).pipe(Effect.map((result) => result.stdout)); + +export const createJjRepo = (cwd: string): Effect.Effect => + runJj(cwd, ["git", "init", "--colocate"]).pipe(Effect.asVoid); + +/** For fixtures a jj repository needs but jj cannot build, such as an external (non-colocated) store. */ +export const runGit = ( + cwd: string, + args: ReadonlyArray, +): Effect.Effect => + Effect.tryPromise({ + try: () => execFileAsync("git", ["-c", "commit.gpgsign=false", ...args], { cwd }), + catch: (cause) => new JjTestCommandError({ command: `git ${args.join(" ")}`, cwd, cause }), + }).pipe(Effect.map((result) => result.stdout)); + +/** The driver over a real subprocess runner, which is what every jj fixture below runs against. */ +export const JjDriverLayer = JjVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +export interface SeedJjRepoInput { + readonly prefix: string; + /** Adds `seed.txt`, commits it and bookmarks `main` on `@-`. */ + readonly seed?: boolean; + /** Creates `remote.git`, wires it up as `origin` and pushes `main`. Implies `seed`. */ + readonly withRemote?: boolean; + /** `false` builds an external-store repository, the shape T3 refuses to operate on. */ + readonly colocated?: boolean; +} + +export interface SeededJjRepo { + /** Scoped temp directory holding the repository, so sibling fixtures cannot collide. */ + readonly base: string; + readonly root: string; + readonly remotePath: string; +} + +export const seedJjRepo = Effect.fnUntraced(function* (input: SeedJjRepoInput) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const created = yield* fileSystem.makeTempDirectoryScoped({ prefix: input.prefix }); + const base = yield* fileSystem.realPath(created); + const root = path.join(base, "project"); + const remotePath = path.join(base, "remote.git"); + yield* fileSystem.makeDirectory(root, { recursive: true }); + + if (input.colocated === false) { + const store = path.join(base, "store.git"); + yield* fileSystem.makeDirectory(store, { recursive: true }); + yield* runGit(store, ["init", "--bare", "--initial-branch=main", store]); + yield* runJj(root, ["git", "init", "--git-repo", store]); + return { base, root, remotePath } satisfies SeededJjRepo; + } + + yield* createJjRepo(root); + if (input.seed === true || input.withRemote === true) { + yield* fileSystem.writeFileString(path.join(root, "seed.txt"), "seed\n"); + yield* runJj(root, ["commit", "-m", "seed"]); + yield* runJj(root, ["bookmark", "create", "main", "-r", "@-"]); + } + if (input.withRemote === true) { + yield* fileSystem.makeDirectory(remotePath, { recursive: true }); + yield* runGit(remotePath, ["init", "--bare", "--initial-branch=main", remotePath]); + yield* runJj(root, ["git", "remote", "add", "origin", remotePath]); + yield* runJj(root, ["git", "push", "--bookmark", "main", "--remote", "origin"]); + } + return { base, root, remotePath } satisfies SeededJjRepo; +}); + +export interface JjRepoFixture extends SeededJjRepo { + readonly driver: JjVcsDriver.JjVcsDriverShape; + readonly process: VcsProcess.VcsProcess["Service"]; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; +} + +/** {@link seedJjRepo} plus the driver, with the real driver layer already provided. */ +export const withJjRepo = ( + input: SeedJjRepoInput, + use: (fixture: JjRepoFixture) => Effect.Effect, +) => + Effect.gen(function* () { + const seeded = yield* seedJjRepo(input); + return yield* use({ + ...seeded, + driver: yield* JjVcsDriver.JjVcsDriver, + process: yield* VcsProcess.VcsProcess, + fileSystem: yield* FileSystem.FileSystem, + path: yield* Path.Path, + }); + }).pipe(Effect.provide(JjDriverLayer)); + +interface RepoPaths { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly root: string; +} + +export const write = (fixture: RepoPaths, relativePath: string, contents: string) => + fixture.fileSystem.writeFileString(fixture.path.join(fixture.root, relativePath), contents); + +export const read = (fixture: RepoPaths, relativePath: string) => + fixture.fileSystem.readFileString(fixture.path.join(fixture.root, relativePath)); + +export const jjOut = (cwd: string, args: ReadonlyArray) => + runJj(cwd, args).pipe(Effect.map((stdout) => stdout.trim())); + +export const commitId = (cwd: string, revset: string) => + jjOut(cwd, ["log", "-r", revset, "--no-graph", "-T", "commit_id"]); + +export const changeId = (cwd: string, revset: string) => + jjOut(cwd, ["log", "-r", revset, "--no-graph", "-T", "change_id"]); + +const unknownSourceControlProvider = SourceControlProvider.SourceControlProvider.pipe( + Effect.provide(Layer.mock(SourceControlProvider.SourceControlProvider)({ kind: "unknown" })), +); + +/** Resolves to an unknown-kind provider. Unstubbed members fail loudly, which a status poll must not hit. */ +export const stubSourceControlProviders = + SourceControlProviderRegistry.SourceControlProviderRegistry.pipe( + Effect.provide( + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => undefined, + resolve: () => unknownSourceControlProvider, + resolveHandle: () => + unknownSourceControlProvider.pipe( + Effect.map((provider) => ({ provider, context: null })), + ), + }), + ), + ); diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts index fd474283590d..a59a443eb0f6 100644 --- a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -71,9 +71,12 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); const identity = yield* driver.detectRepository(cwd); assert.equal(identity?.kind, input.kind); + // `jj root` reports the realpath, so macOS `/var` -> `/private/var` breaks a raw compare. + const fileSystem = yield* FileSystem.FileSystem; + const resolvedCwd = yield* fileSystem.realPath(cwd); assert.isTrue( normalizePathForComparison(identity?.rootPath ?? "").endsWith( - normalizePathForComparison(cwd), + normalizePathForComparison(resolvedCwd), ), ); assert.equal(identity?.freshness.source, "live-local"); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 722dfadc62cb..4afdb862d1ca 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2509,6 +2509,10 @@ const makeWsRpcLayer = ( }, }) .pipe( + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + projectionSnapshotQuery, + ), Effect.matchCauseEffect({ onFailure: (cause) => Queue.failCause(queue, cause), onSuccess: (result) => diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index bd1b11a6cd28..2ee8493c346e 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,3 +1,4 @@ +import { GIT_VCS_TERMINOLOGY, JJ_VCS_TERMINOLOGY } from "@t3tools/shared/vcs"; import { EnvironmentId, type VcsRef } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -101,12 +102,15 @@ describe("resolvePreviousWorktreeSeed", () => { describe("resolvePreviousWorktreeLabel", () => { it("includes the branch when known", () => { - expect(resolvePreviousWorktreeLabel({ branch: "t3/fix-thing", worktreePath: "/wt" })).toBe( - "Previous worktree (t3/fix-thing)", - ); - expect(resolvePreviousWorktreeLabel({ branch: null, worktreePath: "/wt" })).toBe( - "Previous worktree", - ); + expect( + resolvePreviousWorktreeLabel( + { branch: "t3/fix-thing", worktreePath: "/wt" }, + GIT_VCS_TERMINOLOGY, + ), + ).toBe("Previous worktree (t3/fix-thing)"); + expect( + resolvePreviousWorktreeLabel({ branch: null, worktreePath: "/wt" }, GIT_VCS_TERMINOLOGY), + ).toBe("Previous worktree"); }); }); @@ -178,6 +182,22 @@ describe("resolveBranchToolbarValue", () => { }); describe("resolveBranchTriggerLabel", () => { + it("names the empty state with the project's ref noun", () => { + const input = { + activeWorktreePath: null, + effectiveEnvMode: "worktree" as const, + resolvedActiveBranch: null, + resolvedActiveBranchIsRemote: null, + startFromOrigin: true, + }; + expect(resolveBranchTriggerLabel({ ...input, terminology: GIT_VCS_TERMINOLOGY })).toBe( + "Select branch", + ); + expect(resolveBranchTriggerLabel({ ...input, terminology: JJ_VCS_TERMINOLOGY })).toBe( + "Select bookmark", + ); + }); + it("shows the origin ref when a new worktree will start from origin", () => { expect( resolveBranchTriggerLabel({ @@ -186,6 +206,7 @@ describe("resolveBranchTriggerLabel", () => { resolvedActiveBranch: "main", resolvedActiveBranchIsRemote: false, startFromOrigin: true, + terminology: GIT_VCS_TERMINOLOGY, }), ).toBe("From origin/main"); }); @@ -198,6 +219,7 @@ describe("resolveBranchTriggerLabel", () => { resolvedActiveBranch: "feature/demo", resolvedActiveBranchIsRemote: false, startFromOrigin: true, + terminology: GIT_VCS_TERMINOLOGY, }), ).toBe("From origin/feature/demo"); }); @@ -210,6 +232,7 @@ describe("resolveBranchTriggerLabel", () => { resolvedActiveBranch: "main", resolvedActiveBranchIsRemote: false, startFromOrigin: false, + terminology: GIT_VCS_TERMINOLOGY, }), ).toBe("From main"); }); @@ -222,6 +245,7 @@ describe("resolveBranchTriggerLabel", () => { resolvedActiveBranch: "origin/feature/demo", resolvedActiveBranchIsRemote: true, startFromOrigin: true, + terminology: GIT_VCS_TERMINOLOGY, }), ).toBe("From origin/feature/demo"); }); @@ -234,6 +258,7 @@ describe("resolveBranchTriggerLabel", () => { resolvedActiveBranch: "upstream/feature/demo", resolvedActiveBranchIsRemote: true, startFromOrigin: true, + terminology: GIT_VCS_TERMINOLOGY, }), ).toBe("From upstream/feature/demo"); }); @@ -246,6 +271,7 @@ describe("resolveBranchTriggerLabel", () => { resolvedActiveBranch: "main", resolvedActiveBranchIsRemote: false, startFromOrigin: true, + terminology: GIT_VCS_TERMINOLOGY, }), ).toBe("main"); expect( @@ -255,8 +281,9 @@ describe("resolveBranchTriggerLabel", () => { resolvedActiveBranch: null, resolvedActiveBranchIsRemote: null, startFromOrigin: true, + terminology: GIT_VCS_TERMINOLOGY, }), - ).toBe("Select ref"); + ).toBe("Select branch"); }); it("does not fabricate an origin ref while branch metadata is loading", () => { @@ -267,6 +294,7 @@ describe("resolveBranchTriggerLabel", () => { resolvedActiveBranch: "upstream/feature/demo", resolvedActiveBranchIsRemote: null, startFromOrigin: true, + terminology: GIT_VCS_TERMINOLOGY, }), ).toBe("From upstream/feature/demo"); }); @@ -511,18 +539,20 @@ describe("resolveEffectiveEnvMode", () => { describe("resolveEnvModeLabel", () => { it("uses explicit workspace labels", () => { - expect(resolveEnvModeLabel("local")).toBe("Current checkout"); - expect(resolveEnvModeLabel("worktree")).toBe("New worktree"); + expect(resolveEnvModeLabel("local", GIT_VCS_TERMINOLOGY)).toBe("Current checkout"); + expect(resolveEnvModeLabel("worktree", GIT_VCS_TERMINOLOGY)).toBe("New worktree"); }); }); describe("resolveCurrentWorkspaceLabel", () => { it("describes the main repo checkout when no worktree path is active", () => { - expect(resolveCurrentWorkspaceLabel(null)).toBe("Current checkout"); + expect(resolveCurrentWorkspaceLabel(null, GIT_VCS_TERMINOLOGY)).toBe("Current checkout"); }); it("describes the active checkout as a worktree when one is attached", () => { - expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Current worktree"); + expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a", GIT_VCS_TERMINOLOGY)).toBe( + "Current worktree", + ); }); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index cbc887954019..4f473fc691b0 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -7,6 +7,7 @@ import type { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { sanitizeNewRefName } from "@t3tools/shared/git"; +import { DEFAULT_VCS_TERMINOLOGY, type VcsTerminology } from "@t3tools/shared/vcs"; import { toSortableTimestamp } from "../lib/threadSort"; export { dedupeRemoteBranchesWithLocalMatches, @@ -90,8 +91,13 @@ export function resolveContextStripLabelsCompact(input: { : input.neededWidth > input.availableWidth; } -export function resolveEnvModeLabel(mode: EnvMode): string { - return mode === "worktree" ? "New worktree" : "Current checkout"; +export function resolveEnvModeLabel( + mode: EnvMode, + terminology: VcsTerminology = DEFAULT_VCS_TERMINOLOGY, +): string { + return mode === "worktree" + ? `New ${terminology.workspaceNoun}` + : `Current ${terminology.currentRefFallback}`; } export const WORKTREE_SUBMODULES_LABELS: Record = { @@ -100,8 +106,13 @@ export const WORKTREE_SUBMODULES_LABELS: Record = { none: "Skip", }; -export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string { - return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); +export function resolveCurrentWorkspaceLabel( + activeWorktreePath: string | null, + terminology: VcsTerminology = DEFAULT_VCS_TERMINOLOGY, +): string { + return activeWorktreePath + ? `Current ${terminology.workspaceNoun}` + : resolveEnvModeLabel("local", terminology); } // A locked thread in worktree mode with no path is still creating its @@ -109,9 +120,12 @@ export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): export function resolveLockedWorkspaceLabel( activeWorktreePath: string | null, effectiveEnvMode: EnvMode, + terminology: VcsTerminology = DEFAULT_VCS_TERMINOLOGY, ): string { - if (activeWorktreePath) return "Worktree"; - return effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : "Local checkout"; + if (activeWorktreePath) return terminology.workspaceNounTitle; + return effectiveEnvMode === "worktree" + ? resolveEnvModeLabel("worktree", terminology) + : `Local ${terminology.currentRefFallback}`; } export interface PreviousWorktreeSeed { @@ -157,8 +171,13 @@ export function resolvePreviousWorktreeSeed(input: { return latest === null ? null : { branch: latest.branch, worktreePath: latest.worktreePath }; } -export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string { - return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree"; +export function resolvePreviousWorktreeLabel( + seed: PreviousWorktreeSeed, + terminology: VcsTerminology, +): string { + return seed.branch + ? `Previous ${terminology.workspaceNoun} (${seed.branch})` + : `Previous ${terminology.workspaceNoun}`; } export function resolveEffectiveEnvMode(input: { @@ -215,6 +234,7 @@ export function resolveBranchTriggerLabel(input: { resolvedActiveBranch: string | null; resolvedActiveBranchIsRemote: boolean | null; startFromOrigin: boolean; + terminology: VcsTerminology; }): string { const { activeWorktreePath, @@ -222,9 +242,10 @@ export function resolveBranchTriggerLabel(input: { resolvedActiveBranch, resolvedActiveBranchIsRemote, startFromOrigin, + terminology, } = input; if (!resolvedActiveBranch) { - return "Select ref"; + return `Select ${terminology.refNoun}`; } if (effectiveEnvMode === "worktree" && !activeWorktreePath) { const baseRef = diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index eca4fab601d0..606bc7386a44 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -18,6 +18,7 @@ import { useRef, useState, } from "react"; +import type { VcsTerminology } from "@t3tools/shared/vcs"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; @@ -59,6 +60,7 @@ import { useComposerMenuProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; +import { useVcsTerminology } from "../state/vcs"; export interface BranchToolbarHandle { openBranchPicker: () => void; @@ -107,6 +109,7 @@ interface MobileRunContextSelectorProps { previousWorktreeLabel: string | null; previousWorktreeBranch: string | null; onUsePreviousWorktree: () => void; + terminology: VcsTerminology; } const MobileRunContextSelector = memo(function MobileRunContextSelector({ @@ -126,6 +129,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ previousWorktreeLabel, previousWorktreeBranch, onUsePreviousWorktree, + terminology, }: MobileRunContextSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); const activeEnvironment = useMemo( @@ -139,12 +143,12 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ? FolderGitIcon : FolderIcon; const workspaceLabel = forceNewWorktree - ? resolveEnvModeLabel("worktree") + ? resolveEnvModeLabel("worktree", terminology) : envModeLocked - ? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode) + ? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode, terminology) : effectiveEnvMode === "worktree" - ? resolveEnvModeLabel("worktree") - : resolveCurrentWorkspaceLabel(activeWorktreePath); + ? resolveEnvModeLabel("worktree", terminology) + : resolveCurrentWorkspaceLabel(activeWorktreePath, terminology); const isLocked = envLocked || envModeLocked; const workspaceIcon = ( @@ -291,13 +295,17 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ) : ( )} - + - {resolveEnvModeLabel("worktree")} + + {resolveEnvModeLabel("worktree", terminology)} + {previousWorktreeLabel ? ( @@ -548,6 +556,8 @@ export const BranchToolbar = memo(function BranchToolbar({ : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); const effectiveEnvMode = forceNewWorktree ? "worktree" : envMode; const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); + const workspaceCwd = activeWorktreePath ?? activeProject?.workspaceRoot ?? null; + const vcsTerminology = useVcsTerminology(environmentId, workspaceCwd); // "Previous worktree" hops a draft into the most recently active worktree // of this project — the "keep going where I just was" follow-up flow. Only @@ -570,7 +580,7 @@ export const BranchToolbar = memo(function BranchToolbar({ [activeWorktreePath, canUsePreviousWorktree, projectThreads], ); const previousWorktreeLabel = previousWorktreeSeed - ? resolvePreviousWorktreeLabel(previousWorktreeSeed) + ? resolvePreviousWorktreeLabel(previousWorktreeSeed, vcsTerminology) : null; const onUsePreviousWorktree = useCallback(() => { if (!previousWorktreeSeed || !activeProjectRef) return; @@ -648,6 +658,7 @@ export const BranchToolbar = memo(function BranchToolbar({ previousWorktreeLabel={previousWorktreeLabel} previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} + terminology={vcsTerminology} /> ) : null} @@ -688,6 +699,7 @@ export const BranchToolbar = memo(function BranchToolbar({ previousWorktreeLabel={previousWorktreeLabel} previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} + terminology={vcsTerminology} /> ) : null} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 17199bbf3374..52cdbc72a51d 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -34,6 +34,7 @@ import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches" import { usePaginatedBranches } from "../state/queries"; import { useProject, useThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; +import { resolveVcsTerminology } from "@t3tools/shared/vcs"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { vcsEnvironment } from "../state/vcs"; @@ -58,7 +59,6 @@ import { resolveThreadPullRequestBadge, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; -import { Button } from "./ui/button"; import { ComposerControl } from "./chat/ComposerControl"; import { Switch } from "./ui/switch"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; @@ -242,6 +242,7 @@ export function BranchToolbarBranchSelector({ input: { cwd: branchCwd }, }), ); + const vcsTerminology = resolveVcsTerminology(branchStatusQuery.data); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); // The server filters refs by substring, so it has to be given the sanitized @@ -357,35 +358,38 @@ export function BranchToolbarBranchSelector({ const branchStatusText = isInitialBranchesLoadPending ? "Loading refs..." : isFetchingNextPage - ? "Loading more refs..." + ? `Loading more ${vcsTerminology.refNounPlural}...` : hasNextPage - ? `Showing ${refs.length} of ${totalBranchCount} refs` + ? `Showing ${refs.length} of ${totalBranchCount} ${vcsTerminology.refNounPlural}` : null; // --------------------------------------------------------------------------- // Branch actions // --------------------------------------------------------------------------- - const copyBranchName = useCallback((branchName: string) => { - void writeTextToClipboard(branchName, "branch name").then( - (didCopy) => { - if (!didCopy) return; - toastManager.add({ - type: "success", - title: "Branch name copied", - description: branchName, - }); - }, - (error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy branch name", - description: toBranchActionErrorMessage(error), - }), - ); - }, - ); - }, []); + const copyBranchName = useCallback( + (branchName: string) => { + void writeTextToClipboard(branchName, `${vcsTerminology.refNoun} name`).then( + (didCopy) => { + if (!didCopy) return; + toastManager.add({ + type: "success", + title: `${vcsTerminology.refNounTitle} name copied`, + description: branchName, + }); + }, + (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to copy ${vcsTerminology.refNoun} name`, + description: toBranchActionErrorMessage(error), + }), + ); + }, + ); + }, + [vcsTerminology], + ); const handleBranchContextMenu = useCallback( (event: ReactMouseEvent, branchName: string | null) => { @@ -395,13 +399,13 @@ export function BranchToolbarBranchSelector({ event.preventDefault(); event.stopPropagation(); const items: ContextMenuItem<"copy-branch-name">[] = [ - { id: "copy-branch-name", label: "Copy branch name", icon: "copy" }, + { id: "copy-branch-name", label: `Copy ${vcsTerminology.refNoun} name`, icon: "copy" }, ]; void api.contextMenu.show(items, { x: event.clientX, y: event.clientY }).then((action) => { if (action === "copy-branch-name") copyBranchName(branchName); }); }, - [copyBranchName], + [copyBranchName, vcsTerminology], ); const runBranchAction = (action: () => Promise) => { @@ -465,7 +469,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to switch ref.", + title: `Failed to switch ${vcsTerminology.refNoun}.`, description: toBranchActionErrorMessage(squashAtomCommandFailure(checkoutResult)), }), ); @@ -501,7 +505,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to create and switch ref.", + title: `Failed to create and switch ${vcsTerminology.refNoun}.`, description: toBranchActionErrorMessage(squashAtomCommandFailure(createBranchResult)), }), ); @@ -648,6 +652,7 @@ export function BranchToolbarBranchSelector({ resolvedActiveBranch, resolvedActiveBranchIsRemote, startFromOrigin, + terminology: vcsTerminology, }); // Branch status is the fallback when this thread has no linked pull requests. @@ -727,7 +732,9 @@ export function BranchToolbarBranchSelector({ value={itemValue} onClick={() => selectPickerItem(itemValue)} > - Create new ref "{newRefName}" + + Create new {vcsTerminology.refNoun} "{newRefName}" + ); } @@ -835,7 +842,7 @@ export function BranchToolbarBranchSelector({ {...composerFloatingLayerProps} > setBranchQuery(event.target.value)} onKeyDown={(event) => { @@ -858,7 +865,7 @@ export function BranchToolbarBranchSelector({ }} />
- No refs found. + No {vcsTerminology.refNounPlural} found.
@@ -911,15 +918,15 @@ export function BranchToolbarBranchSelector({ id={startFromOriginSwitchId} checked={startFromOrigin} size="sm" - aria-label="Start worktree from origin" + aria-label={`Start ${vcsTerminology.workspaceNoun} from origin`} onCheckedChange={(checked) => onStartFromOriginChange(Boolean(checked))} /> } /> - Creates the worktree from the latest matching branch on origin instead of your local - branch. + Creates the {vcsTerminology.workspaceNoun} from the latest matching{" "} + {vcsTerminology.refNoun} on origin instead of your local {vcsTerminology.refNoun}. ) : null} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 40692063e979..d09cd916f33c 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -1,5 +1,6 @@ import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; import { memo, useMemo } from "react"; +import type { VcsTerminology } from "@t3tools/shared/vcs"; import { resolveCurrentWorkspaceLabel, @@ -31,6 +32,7 @@ interface BranchToolbarEnvModeSelectorProps { previousWorktreeLabel?: string | null; previousWorktreeBranch?: string | null; onUsePreviousWorktree?: () => void; + terminology: VcsTerminology; } export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ @@ -42,18 +44,19 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe previousWorktreeLabel, previousWorktreeBranch = null, onUsePreviousWorktree, + terminology, }: BranchToolbarEnvModeSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); const envModeItems = useMemo( () => [ - { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, - { value: "worktree", label: resolveEnvModeLabel("worktree") }, + { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath, terminology) }, + { value: "worktree", label: resolveEnvModeLabel("worktree", terminology) }, ...(showPreviousWorktree && previousWorktreeLabel ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }] : []), ], - [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], + [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree, terminology], ); if (envLocked || forceNewWorktree) { @@ -79,14 +82,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe data-composer-label-motion className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-drawer group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none" > - {resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)} + {resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode, terminology)} {forceNewWorktree - ? "Each model starts in its own worktree." - : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)} + ? `Each model starts in its own ${terminology.workspaceNoun}.` + : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode, terminology)} ); @@ -139,8 +142,8 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {effectiveEnvMode === "worktree" - ? resolveEnvModeLabel("worktree") - : resolveCurrentWorkspaceLabel(activeWorktreePath)} + ? resolveEnvModeLabel("worktree", terminology) + : resolveCurrentWorkspaceLabel(activeWorktreePath, terminology)} )} - {resolveCurrentWorkspaceLabel(activeWorktreePath)} + {resolveCurrentWorkspaceLabel(activeWorktreePath, terminology)} - {resolveEnvModeLabel("worktree")} + {resolveEnvModeLabel("worktree", terminology)} {showPreviousWorktree && previousWorktreeLabel ? ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9ee2f6431f07..eea932a68c0f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -357,6 +357,7 @@ import { vcsEnvironment } from "../state/vcs"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useProjectClone } from "../state/projectClones"; import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/contracts"; +import { resolveVcsTerminology } from "@t3tools/shared/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { useProject, @@ -3705,6 +3706,7 @@ export default function ChatView(props: ChatViewProps) { input: { cwd: gitStatusCwd }, }), ); + const vcsTerminology = resolveVcsTerminology(gitStatusQuery.data); useWorkspaceMutationRefresh({ enabled: gitStatusCwd !== null, mutationId: workspaceMutationId, @@ -6634,10 +6636,10 @@ export default function ChatView(props: ChatViewProps) { disabled={isRestoringThreadBranch} onClick={handleRestoreThreadBranch} > - {isRestoringThreadBranch ? "Restoring..." : "Restore branch"} + {isRestoringThreadBranch ? "Restoring..." : `Restore ${vcsTerminology.refNoun}`} ), - dismissLabel: "Dismiss branch change notice", + dismissLabel: `Dismiss ${vcsTerminology.refNoun} change notice`, onDismiss: () => { dismissBranchMismatchForSession(activeBranchMismatchKey); setBranchMismatchDismissTick((tick) => tick + 1); @@ -6658,6 +6660,7 @@ export default function ChatView(props: ChatViewProps) { showBranchMismatchBanner, systemComposerBannerItems, usageLimitsBanner, + vcsTerminology, wokeThreadBannerItem, ]); useEffect(() => { @@ -10202,6 +10205,7 @@ export default function ChatView(props: ChatViewProps) { composerRef={composerRef} composerDraftTarget={composerDraftTarget} environmentId={environmentId} + workspaceNoun={vcsTerminology.workspaceNoun} attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} supportsAttachmentUploads={supportsAttachmentUploads} supportsQuestionAttachments={supportsQuestionAttachments} @@ -10230,7 +10234,7 @@ export default function ChatView(props: ChatViewProps) { : threadDetailLoading ? "Messages loading" : worktreeSetupBlocksSend - ? "Preparing worktree" + ? `Preparing ${vcsTerminology.workspaceNoun}` : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 78961f91caf5..418e4d7fc7ba 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -83,6 +83,7 @@ import { } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { useEnvironmentQuery } from "../state/query"; +import { resolveVcsTerminology } from "@t3tools/shared/vcs"; import { useAtomCommand } from "../state/use-atom-command"; import { serverEnvironment } from "../state/server"; import { reviewEnvironment } from "../state/review"; @@ -197,6 +198,8 @@ export default function DiffPanel({ selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), ); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const vcsTerminology = resolveVcsTerminology(gitStatusQuery.data); + const workingTreeLabel = vcsTerminology.workingTreeNounTitle; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const orderedTurnDiffSummaries = useMemo( @@ -240,8 +243,8 @@ export default function DiffPanel({ const selectedScopeLabel = selectedTurnId === null ? selectedGitScope === "unstaged" - ? "Working tree" - : "Branch changes" + ? workingTreeLabel + : `${vcsTerminology.refNounTitle} changes` : selectedTurn?.turnId === latestTurn?.turnId ? "Latest turn" : `Turn ${selectedCheckpointTurnCount ?? "?"}`; @@ -253,8 +256,8 @@ export default function DiffPanel({ const reviewSectionTitle = selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` : selectedGitScope === "unstaged" - ? "Working tree" - : "Branch changes"; + ? workingTreeLabel + : `${vcsTerminology.refNounTitle} changes`; const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" @@ -748,10 +751,10 @@ export default function DiffPanel({ - Working tree + {workingTreeLabel} - Branch changes + {vcsTerminology.refNounTitle} changes Latest turn @@ -827,18 +830,18 @@ export default function DiffPanel({ className="w-72 min-w-0 max-w-[calc(100vw-1rem)] overflow-hidden" > setBaseRefQuery(event.target.value)} />
- No matching refs. + No matching {vcsTerminology.refNounPlural}. ) : !isGitRepo ? (
- Turn diffs are unavailable because this project is not a git repository. + {`Turn diffs are unavailable because this project is not a ${vcsTerminology.systemName} repository.`}
) : selectedTurnId !== null && orderedTurnDiffSummaries.length === 0 ? (
diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f302e976ca70..1b9b0a089069 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -381,7 +381,7 @@ describe("when: ref has diverged from upstream", () => { it("resolveQuickAction returns a disabled sync hint", () => { const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false); assert.deepEqual(quick, { - label: "Sync ref", + label: "Sync branch", disabled: true, kind: "show_hint", hint: "Branch has diverged from upstream. Rebase/merge first.", @@ -731,7 +731,7 @@ describe("when: ref has no upstream configured", () => { }); }); - it("resolveQuickAction publishes when no origin remote exists", () => { + it("resolveQuickAction publishes when no origin remote exists and the surface can publish", () => { const quick = resolveQuickAction( status({ hasUpstream: false, @@ -741,6 +741,7 @@ describe("when: ref has no upstream configured", () => { false, false, false, + true, ); assert.deepEqual(quick, { kind: "open_publish", @@ -749,6 +750,21 @@ describe("when: ref has no upstream configured", () => { }); }); + it("resolveQuickAction never publishes on a surface without the flow", () => { + const quick = resolveQuickAction( + status({ + hasUpstream: false, + aheadCount: 2, + pr: null, + }), + false, + false, + false, + ); + assert.equal(quick.kind, "show_hint"); + assert.isTrue(quick.disabled); + }); + it("buildMenuItems enables create PR when no upstream and commits are ahead", () => { const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 2 }), false); assert.deepEqual(items, [ @@ -895,9 +911,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Push to default ref?", + title: "Push to default branch?", description: - 'This action will push local commits on "main". You can continue on this ref or create a feature ref and run the same action there.', + 'This action will push local commits on "main". You can continue on this branch or create a feature branch and run the same action there.', continueLabel: "Push to main", }); }); @@ -910,9 +926,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Push & create PR from default ref?", + title: "Push & create PR from default branch?", description: - 'This action will push local commits and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', + 'This action will push local commits and create a pull request on "main". You can continue on this branch or create a feature branch and run the same action there.', continueLabel: "Push & create PR", }); }); @@ -925,9 +941,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Commit, push & create PR from default ref?", + title: "Commit, push & create PR from default branch?", description: - 'This action will commit, push, and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', + 'This action will commit, push, and create a pull request on "main". You can continue on this branch or create a feature branch and run the same action there.', continueLabel: "Commit, push & create PR", }); }); @@ -1003,6 +1019,16 @@ describe("buildGitActionProgressStages", () => { "Creating pull request...", ]); }); + + it("names the feature ref with Git nouns when no VCS terminology is supplied", () => { + const stages = buildGitActionProgressStages({ + action: "commit", + hasCustomCommitMessage: true, + hasWorkingTreeChanges: true, + featureBranch: true, + }); + assert.deepEqual(stages, ["Preparing feature branch...", "Committing..."]); + }); }); describe("resolveThreadBranchUpdate", () => { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 40f71b5d85ae..310e8c2206fa 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -4,6 +4,11 @@ import type { VcsStatusResult, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; +import { + DEFAULT_VCS_TERMINOLOGY, + resolveVcsTerminology, + type VcsTerminology, +} from "@t3tools/shared/vcs"; import { DEFAULT_CHANGE_REQUEST_TERMINOLOGY, getChangeRequestTerminology, @@ -59,9 +64,11 @@ export function buildGitActionProgressStages(input: { featureBranch?: boolean; shouldPushBeforePr?: boolean; terminology?: ChangeRequestTerminology; + vcsTerminology?: VcsTerminology; }): string[] { const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; - const branchStages = input.featureBranch ? ["Preparing feature ref..."] : []; + const vcs = input.vcsTerminology ?? DEFAULT_VCS_TERMINOLOGY; + const branchStages = input.featureBranch ? [`Preparing feature ${vcs.refNoun}...`] : []; const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; const prStages = [ `Preparing ${terminology.shortLabel}...`, @@ -169,6 +176,7 @@ export function resolveQuickAction( isBusy: boolean, isDefaultRef = false, hasPrimaryRemote = true, + canPublishRepository = false, ): GitQuickAction { if (isBusy) { return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; @@ -191,13 +199,14 @@ export function resolveQuickAction( const isBehind = gitStatus.behindCount > 0; const isDiverged = isAhead && isBehind; const terminology = resolveChangeRequestTerminology(gitStatus); + const vcs = resolveVcsTerminology(gitStatus); if (!hasBranch) { return { label: "Commit", disabled: true, kind: "show_hint", - hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`, + hint: `Create and check out a ${vcs.refNoun} before pushing or opening a ${terminology.singular}.`, }; } @@ -221,11 +230,14 @@ export function resolveQuickAction( if (hasOpenPr && !isAhead) { return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; } - return { - label: "Publish repository", - disabled: false, - kind: "open_publish", - }; + return canPublishRepository + ? { label: "Publish repository", disabled: false, kind: "open_publish" } + : { + label: "Publish repository", + disabled: true, + kind: "show_hint", + hint: "Publishing is unavailable here.", + }; } if (!isAhead) { if (hasOpenPr) { @@ -256,10 +268,10 @@ export function resolveQuickAction( if (isDiverged) { return { - label: "Sync ref", + label: `Sync ${vcs.refNoun}`, disabled: true, kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", + hint: `${vcs.refNounTitle} has diverged from upstream. Rebase/merge first.`, }; } @@ -305,7 +317,7 @@ export function resolveQuickAction( label: "Commit", disabled: true, kind: "show_hint", - hint: "Branch is up to date. No action needed.", + hint: `${vcs.refNounTitle} is up to date. No action needed.`, }; } @@ -327,21 +339,23 @@ export function resolveDefaultBranchActionDialogCopy(input: { branchName: string; includesCommit: boolean; terminology?: ChangeRequestTerminology; + vcsTerminology?: VcsTerminology; }): DefaultBranchActionDialogCopy { const branchLabel = input.branchName; - const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`; + const vcs = input.vcsTerminology ?? DEFAULT_VCS_TERMINOLOGY; + const suffix = ` on "${branchLabel}". You can continue on this ${vcs.refNoun} or create a feature ${vcs.refNoun} and run the same action there.`; const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; if (input.action === "push" || input.action === "commit_push") { if (input.includesCommit) { return { - title: "Commit & push to default ref?", + title: `Commit & push to default ${vcs.refNoun}?`, description: `This action will commit and push changes${suffix}`, continueLabel: `Commit & push to ${branchLabel}`, }; } return { - title: "Push to default ref?", + title: `Push to default ${vcs.refNoun}?`, description: `This action will push local commits${suffix}`, continueLabel: `Push to ${branchLabel}`, }; @@ -349,13 +363,13 @@ export function resolveDefaultBranchActionDialogCopy(input: { if (input.includesCommit) { return { - title: `Commit, push & create ${terminology.shortLabel} from default ref?`, + title: `Commit, push & create ${terminology.shortLabel} from default ${vcs.refNoun}?`, description: `This action will commit, push, and create a ${terminology.singular}${suffix}`, continueLabel: `Commit, push & create ${terminology.shortLabel}`, }; } return { - title: `Push & create ${terminology.shortLabel} from default ref?`, + title: `Push & create ${terminology.shortLabel} from default ${vcs.refNoun}?`, description: `This action will push local commits and create a ${terminology.singular}${suffix}`, continueLabel: `Push & create ${terminology.shortLabel}`, }; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f9bdcbefe8cc..e7d7e4512b97 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -13,6 +13,7 @@ import type { SourceControlProviderKind, SourceControlPublishRepositoryResult, SourceControlRepositoryVisibility, + VcsDriverKind, VcsStatusResult, } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; @@ -113,6 +114,7 @@ import { randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; +import { getVcsTerminology, resolveVcsTerminology } from "@t3tools/shared/vcs"; import { useOpenLink } from "~/browser/useOpenLink"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; @@ -317,23 +319,24 @@ function getMenuActionDisabledReason({ const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology; + const vcs = resolveVcsTerminology(gitStatus); if (item.id === "commit") { if (!hasChanges) { - return "Worktree is clean. Make changes before committing."; + return `${vcs.workspaceNounTitle} is clean. Make changes before committing.`; } return "Commit is currently unavailable."; } if (item.id === "push") { if (!hasBranch) { - return "Detached HEAD: check out a branch before pushing."; + return `Check out a ${vcs.refNoun} before pushing.`; } if (hasChanges) { return "Commit or stash local changes before pushing."; } if (isBehind) { - return "Branch is behind upstream. Pull/rebase before pushing."; + return `${vcs.refNounTitle} is behind upstream. Pull/rebase before pushing.`; } if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return 'Add an "origin" remote before pushing.'; @@ -348,7 +351,7 @@ function getMenuActionDisabledReason({ return `View ${terminology.singular} is currently unavailable.`; } if (!hasBranch) { - return `Detached HEAD: check out a branch before creating a ${terminology.singular}.`; + return `Check out a ${vcs.refNoun} before creating a ${terminology.singular}.`; } if (hasChanges) { return `Commit local changes before creating a ${terminology.singular}.`; @@ -360,7 +363,7 @@ function getMenuActionDisabledReason({ return `No local commits to include in a ${terminology.singular}.`; } if (isBehind) { - return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; + return `${vcs.refNounTitle} is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; } return `Create ${terminology.singular} is currently unavailable.`; } @@ -979,6 +982,7 @@ export default function GitActionsControl({ const [excludedFiles, setExcludedFiles] = useState>(new Set()); const [isEditingFiles, setIsEditingFiles] = useState(false); const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); + const [isConvertToJjDialogOpen, setIsConvertToJjDialogOpen] = useState(false); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); const activeGitActionProgressRef = useRef(null); @@ -1076,6 +1080,7 @@ export default function GitActionsControl({ [gitStatus?.sourceControlProvider], ); const changeRequestTerminology = sourceControlPresentation.terminology; + const vcsTerminology = resolveVcsTerminology(gitStatus); const SourceControlIcon = sourceControlPresentation.Icon; // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; @@ -1088,6 +1093,15 @@ export default function GitActionsControl({ const noneSelected = selectedFiles.length === 0; const initAction = useVcsInitAction(sourceControlScope); + const vcsDiscovery = useEnvironmentQuery( + activeEnvironmentId !== null && !isRepo + ? sourceControlEnvironment.discovery({ environmentId: activeEnvironmentId, input: {} }) + : null, + ); + const canInitJujutsu = + vcsDiscovery.data?.versionControlSystems.some( + (item) => item.kind === "jj" && item.status === "available" && item.implemented, + ) ?? false; const runImmediateGitAction = useGitStackedAction(sourceControlScope); const pullAction = useVcsPullAction(sourceControlScope); const isGitActionRunning = useSourceControlActionRunning( @@ -1134,7 +1148,13 @@ export default function GitActionsControl({ ); const quickAction = useMemo( () => - resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), + resolveQuickAction( + gitStatusForActions, + isGitActionRunning, + isDefaultRef, + hasPrimaryRemote, + true, + ), [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled @@ -1146,6 +1166,7 @@ export default function GitActionsControl({ branchName: pendingDefaultBranchAction.branchName, includesCommit: pendingDefaultBranchAction.includesCommit, terminology: changeRequestTerminology, + vcsTerminology, }) : null; @@ -1632,11 +1653,9 @@ export default function GitActionsControl({ [gitCwd, openInPreferredEditor, threadToastData], ); - const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; - - const initializeGit = () => { - void (async () => { - const result = await initAction.run(); + const runInit = useCallback( + async (kind: VcsDriverKind) => { + const result = await initAction.run(kind); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } @@ -1644,13 +1663,17 @@ export default function GitActionsControl({ toastManager.add( stackedThreadToast({ type: "error", - title: "Git initialization failed", + title: `${getVcsTerminology(kind).systemName} initialization failed`, description: error instanceof Error ? error.message : "An error occurred.", ...(threadToastData !== undefined ? { data: threadToastData } : {}), }), ); - })(); - }; + }, + [initAction, threadToastData], + ); + + const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; + const gitItems = ( <> {gitActionMenuItems.map((item) => { @@ -1721,9 +1744,22 @@ export default function GitActionsControl({ Publish repository... ) : null} + {gitStatusForActions?.isRepo && + (gitStatusForActions.vcs?.kind === undefined || gitStatusForActions.vcs.kind === "git") && + !activeServerThread?.worktreePath && + !activeDraftThread?.worktreePath ? ( + setIsConvertToJjDialogOpen(true)} + > + + Enable Jujutsu... + + ) : null} {gitStatusForActions?.refName === null && (

- Detached HEAD: create and check out a branch to enable push and pull request actions. + Create and check out a {vcsTerminology.refNoun} to enable push and pull request actions.

)} {gitStatusForActions && @@ -1741,19 +1777,58 @@ export default function GitActionsControl({ return ( <> + + + + Enable Jujutsu for this Git repository? + + Jujutsu will use this repository's existing Git store. Commits, branches, remotes, and + uncommitted files stay in place. The main repository will open in Jujutsu mode; + existing Git worktrees continue to use Git. + + + + + + + + {presentation === "menu" ? ( !isRepo ? ( - - - - {initAction.isPending ? "Initializing..." : "Initialize Git"} - - + <> + void runInit("git")} + > + + + {initAction.isPending ? "Initializing..." : "Initialize Git"} + + + {canInitJujutsu && ( + void runInit("jj")} + > + + + {initAction.isPending ? "Initializing..." : "Initialize Jujutsu"} + + + )} + ) : ( <> - Git actions + {vcsTerminology.systemName} actions {gitItems} ) ) : !isRepo ? ( - + + + {canInitJujutsu && ( + + )} + ) : ( - + {quickActionDisabledReason ? ( } + render={ + diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index a2adddf296c8..26dc6014bff9 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -12,6 +12,7 @@ import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "~/pullRequestReference"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useEnvironmentQuery } from "~/state/query"; +import { resolveVcsTerminology } from "@t3tools/shared/vcs"; import { vcsEnvironment } from "~/state/vcs"; import { Button } from "./ui/button"; import { @@ -67,6 +68,7 @@ export function PullRequestThreadDialog({ [gitStatus?.sourceControlProvider], ); const terminology = sourceControlPresentation.terminology; + const vcsTerminology = resolveVcsTerminology(gitStatus); const SourceControlIcon = sourceControlPresentation.Icon; useEffect(() => { @@ -298,7 +300,9 @@ export function PullRequestThreadDialog({ preparePullRequestThreadAction.isPending } > - {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"} + {preparingMode === "worktree" + ? `Preparing ${vcsTerminology.workspaceNoun}...` + : vcsTerminology.workspaceNounTitle} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index fd95612a9c14..df30f61794f2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1196,6 +1196,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( reserveContextWindowMeter: boolean; activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; + workspaceNoun: string; pendingAction: { questionIndex: number; isLastQuestion: boolean; @@ -1243,6 +1244,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isConnecting={props.isConnecting} isEnvironmentUnavailable={props.isEnvironmentUnavailable} isPreparingWorktree={props.isPreparingWorktree} + workspaceNoun={props.workspaceNoun} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} @@ -1331,6 +1333,7 @@ export interface ChatComposerProps { supportsQuestionAttachments: boolean; maxFileAttachmentBytes: number | null; routeKind: "server" | "draft"; + workspaceNoun: string; routeThreadRef: ScopedThreadRef; draftId: DraftId | null; multipleModelSelections: ReadonlyArray | null; @@ -1492,6 +1495,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { composerDraftTarget, environmentId, + workspaceNoun, attachmentUploadsCapabilityKnown, supportsAttachmentUploads, supportsQuestionAttachments, @@ -6383,6 +6387,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectSelectionRequired } isPreparingWorktree={false} + workspaceNoun={workspaceNoun} hasSendableContent={false} preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} @@ -7004,6 +7009,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectSelectionRequired } isPreparingWorktree={false} + workspaceNoun={workspaceNoun} hasSendableContent={false} preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} @@ -7113,6 +7119,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectSelectionRequired } isPreparingWorktree={isPreparingWorktree} + workspaceNoun={workspaceNoun} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 76ea907b84cd..a4a2110067fa 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,5 +1,6 @@ import { memo, type PointerEventHandler } from "react"; import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; +import { DEFAULT_VCS_TERMINOLOGY } from "@t3tools/shared/vcs"; import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; @@ -27,6 +28,8 @@ interface ComposerPrimaryActionsProps { isConnecting: boolean; isEnvironmentUnavailable: boolean; isPreparingWorktree: boolean; + /** The project's VCS noun for a worktree; Git's is the fallback while status is unknown. */ + workspaceNoun?: string; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; onPreviousPendingQuestion: () => void; @@ -72,6 +75,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isConnecting, isEnvironmentUnavailable, isPreparingWorktree, + workspaceNoun = DEFAULT_VCS_TERMINOLOGY.workspaceNoun, hasSendableContent, preserveComposerFocusOnPointerDown = false, onPreviousPendingQuestion, @@ -236,7 +240,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ : isConnecting ? "Connecting" : isPreparingWorktree - ? "Preparing worktree" + ? `Preparing ${workspaceNoun}` : isSendBusy ? "Sending" : isRunning diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 1b42fdd4481a..d5add5d1b67a 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -89,7 +89,7 @@ import { import { useAtomCommand } from "~/state/use-atom-command"; import { PullRequestStackMenu } from "./PullRequestStackMenu"; import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; -import { vcsEnvironment } from "~/state/vcs"; +import { useVcsTerminology, vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { useUiStateStore } from "~/uiStateStore"; @@ -951,6 +951,7 @@ export function PullRequestDetailPanel({ const actingEnvironmentId = acting?.environmentId ?? environmentId; const checkoutRoot = acting?.workspaceRoot ?? detail?.workspaceRoot ?? project?.workspaceRoot ?? null; + const vcsTerminology = useVcsTerminology(actingEnvironmentId, checkoutRoot); const prepareThread = usePreparePullRequestThreadAction({ environmentId: actingEnvironmentId, cwd: checkoutRoot, @@ -1292,7 +1293,7 @@ export function PullRequestDetailPanel({ description: mode === "local" ? "This repository is on the pull request's branch, with a thread open on it." - : "The pull request is in its own worktree, with a thread open on it.", + : `The pull request is in its own ${vcsTerminology.workspaceNoun}, with a thread open on it.`, } : staleCheckoutToast, ); diff --git a/apps/web/src/components/settings/ProjectActionsSettings.tsx b/apps/web/src/components/settings/ProjectActionsSettings.tsx index f9b7b44ec0c8..d00df49cc0be 100644 --- a/apps/web/src/components/settings/ProjectActionsSettings.tsx +++ b/apps/web/src/components/settings/ProjectActionsSettings.tsx @@ -4,6 +4,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { DEFAULT_VCS_TERMINOLOGY } from "@t3tools/shared/vcs"; import { ChevronDownIcon, PlusIcon } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; @@ -136,7 +137,7 @@ export function ProjectActionsSettings() { settingKeys={["defaultProjectScripts"]} mixed={mixed} title="Actions" - description="Commands that run in this project's checkout or its worktree, with optional shortcuts." + description={`Commands that run in this project's ${DEFAULT_VCS_TERMINOLOGY.currentRefFallback} or its ${DEFAULT_VCS_TERMINOLOGY.workspaceNoun}, with optional shortcuts.`} onResetOverride={() => void persist(() => null)} control={
diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 62a58307aaf5..bf41716ae5d3 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -16,6 +16,7 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { useEnvironments } from "../../state/environments"; +import { useVcsTerminology } from "../../state/vcs"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; @@ -60,6 +61,10 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const representative = target ? environments.find((environment) => environment.environmentId === target.environmentId) : undefined; + const workspaceRoot = + scope.members.find((member) => member.environmentId === target?.environmentId)?.workspaceRoot ?? + null; + const vcsTerminology = useVcsTerminology(target?.environmentId ?? null, workspaceRoot); const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; const selection = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection); const entries = sortProviderInstanceEntries( @@ -79,6 +84,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]); const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]); const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); + const mixedVcsHints = useScopedSettingsMixed(["enableVcsAgentHints"]); const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]); const modelSource = useScopedSettingSource(["defaultModelSelection"]); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; @@ -231,7 +237,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting {(value: string | null) => value === "local" || value === "worktree" - ? resolveEnvModeLabel(value) + ? resolveEnvModeLabel(value, vcsTerminology) : unavailable ? "Unavailable" : "Mixed" @@ -239,8 +245,10 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting - {resolveEnvModeLabel("local")} - {resolveEnvModeLabel("worktree")} + {resolveEnvModeLabel("local", vcsTerminology)} + + {resolveEnvModeLabel("worktree", vcsTerminology)} + } @@ -383,6 +391,24 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ) : category === "source-control" ? ( <> + + updateSettings({ enableVcsAgentHints: checked })} + /> + } + /> + {setting.title} - This interval is configured for Git only. The shared Background activity policy still - decides whether Git refreshes may run when the timer fires. Custom intervals appear as - Advanced in General settings. + This interval drives the background remote refresh for whichever version control + system a project uses. The shared Background activity policy still decides whether a + refresh may run when the timer fires. Custom intervals appear as Advanced in General + settings.

- Refresh remote branches in the background. Set to 0 to avoid automatic Git prompts. + Refresh remote refs in the background. Set to 0 to avoid automatic remote prompts.

@@ -568,10 +569,11 @@ export function SourceControlSettingsPanel() { headerAction={scanButton} > {result.versionControlSystems.map((item) => ( - - {item.kind === "git" ? : undefined} - + ))} +
+ +
) : null} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 1de1674b8cb0..ab11b96bb65c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -472,7 +472,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Start from origin", to: "/settings/general", scope: "project-defaults", - searchTerms: ["new worktrees latest matching remote branch local"], + searchTerms: ["new worktrees workspaces latest matching remote branch bookmark local"], }, { id: "add-project-starts-in", @@ -733,7 +733,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/source-control", scope: "environment-defaults", searchTerms: [ - "version control git github gitlab forgejo gitea tea codeberg bitbucket azure devops hosting integrations credentials scan server environment", + "version control git jujutsu jj bookmark workspace change github gitlab forgejo gitea tea codeberg bitbucket azure devops hosting integrations credentials scan server environment", ], }, { @@ -741,11 +741,20 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Git fetch interval", to: "/settings/source-control", searchTerms: [ - "automatic remote branch refresh background credentials security keys seconds off", + "automatic background remote ref branch bookmark refresh git jujutsu jj credentials security keys seconds off", ], environmentOnly: true, scope: "environment-defaults", }, + { + id: "vcs-agent-hints", + title: "VCS agent hints", + to: "/settings/source-control", + scope: "environment-defaults", + searchTerms: [ + "version control git jujutsu jj agent context instructions injection hints disable", + ], + }, { id: "source-control-writing-style", title: "Source control writing style", @@ -776,7 +785,9 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "project-actions", title: "Actions", to: "/settings/projects", - searchTerms: ["commands scripts setup run dev server checkout worktree t3.json import"], + searchTerms: [ + "commands scripts setup run dev server checkout worktree jujutsu jj bookmark workspace change t3.json import", + ], }, { id: "environment-icon", diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 838fc91a636c..0acd0f0a7540 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -1,4 +1,5 @@ import type { ContextMenuItem } from "@t3tools/contracts"; +import { DEFAULT_VCS_TERMINOLOGY, type VcsTerminology } from "@t3tools/shared/vcs"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; /** @@ -41,6 +42,7 @@ export interface ThreadActionMenuState { /** True when the list is already scoped to this thread's project. */ readonly isActive: boolean; } | null; + readonly terminology?: VcsTerminology; readonly isPinned: boolean; readonly isSettled: boolean; /** False while the user has turned automatic settlement off for this thread. */ @@ -171,7 +173,13 @@ export function buildThreadActionMenuItems( children: [ { id: "copy-path", label: "Path", icon: "folder" }, ...(state.branch - ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] + ? [ + { + id: "copy-branch" as const, + label: (state.terminology ?? DEFAULT_VCS_TERMINOLOGY).refNounTitle, + icon: "git-branch", + }, + ] : []), { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, ], diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 6a82b920ab31..5b7ae272a623 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -4,11 +4,13 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; +import { resolveVcsTerminology } from "@t3tools/shared/vcs"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { useRouter } from "@tanstack/react-router"; @@ -379,6 +381,20 @@ export function useThreadActions() { environmentId: threadRef.environmentId, projectId: thread.projectId, }); + const terminology = resolveVcsTerminology( + threadProject + ? Option.getOrNull( + AsyncResult.value( + appAtomRegistry.get( + vcsEnvironment.status({ + environmentId: threadRef.environmentId, + input: { cwd: threadProject.workspaceRoot }, + }), + ), + ), + ) + : null, + ); const deletedIds = opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0 ? new Set( @@ -412,10 +428,10 @@ export function useThreadActions() { const confirmationResult = await settlePromise(() => localApi.dialogs.confirm( [ - "This thread is the only one linked to this worktree:", + `This thread is the only one linked to this ${terminology.workspaceNoun}:`, displayWorktreePath ?? orphanedWorktreePath, "", - "Delete the worktree too?", + `Delete the ${terminology.workspaceNoun} too?`, ].join("\n"), { variant: "destructive" }, ), @@ -521,8 +537,8 @@ export function useThreadActions() { stackedThreadToast({ type: "error", title: removalFailed - ? "Failed to delete worktree" - : "Worktree deleted, but Git status refresh failed", + ? `Failed to delete ${terminology.workspaceNoun}` + : `${terminology.workspaceNounTitle} deleted, but ${terminology.systemName} status refresh failed`, description: removalFailed ? `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}` : message, diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index efda37c14403..05e9206cd460 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -16,6 +16,7 @@ import type { SourceControlCloneProtocol, SourceControlRepositoryVisibility, ThreadId, + VcsDriverKind, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; @@ -140,24 +141,27 @@ export function useSourceControlActionRunning( export function useVcsInitAction(scope: SourceControlActionScope) { const init = useAtomCommand(vcsEnvironment.init, { reportFailure: false }); - const action = useCallback(async () => { - const target = resolveScope(scope); - if (target === null) { - return AsyncResult.failure( - Cause.fail( - new VcsActionUnavailableError({ - operation: "init", - environmentId: scope.environmentId, - cwd: scope.cwd, - }), - ), - ); - } - return init({ - environmentId: target.environmentId, - input: { cwd: target.cwd }, - }); - }, [init, scope]); + const action = useCallback( + async (kind?: VcsDriverKind) => { + const target = resolveScope(scope); + if (target === null) { + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "init", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); + } + return init({ + environmentId: target.environmentId, + input: { cwd: target.cwd, ...(kind ? { kind } : {}) }, + }); + }, + [init, scope], + ); return useAction({ kind: "init", label: "Initializing repository", scope, action }); } diff --git a/apps/web/src/state/vcs.ts b/apps/web/src/state/vcs.ts index dc8c251149f6..fd8abd6d2e39 100644 --- a/apps/web/src/state/vcs.ts +++ b/apps/web/src/state/vcs.ts @@ -1,9 +1,25 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import { createVcsActionManager, createVcsEnvironmentAtoms, } from "@t3tools/client-runtime/state/vcs"; +import { resolveVcsTerminology, type VcsTerminology } from "@t3tools/shared/vcs"; import { connectionAtomRuntime } from "../connection/runtime"; +import { useEnvironmentQuery } from "./query"; export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); + +/** Reads the shared status atom for (environment, cwd); Git terms until it answers. */ +export function useVcsTerminology( + environmentId: EnvironmentId | null, + cwd: string | null, +): VcsTerminology { + const status = useEnvironmentQuery( + environmentId === null || cwd === null + ? null + : vcsEnvironment.status({ environmentId, input: { cwd } }), + ); + return resolveVcsTerminology(status.data); +} diff --git a/docs/README.md b/docs/README.md index 09c6269ed589..5fdbf88215b9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,7 @@ source alone does not explain. Most code changes do not need an internal documen - [Terminal runtime](./internals/terminal-runtime.md) - [Devices](./internals/devices.md) - [Voice input](./internals/voice-input.md) +- [Jujutsu](./internals/jujutsu.md) ### Runbooks diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index 019c9d53023a..81422f4b9196 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -17,6 +17,17 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain | Activity | A non-message timeline item, such as a tool action, approval, or failure. | | T3 home | The base data directory. Runtime state normally lives under its `userdata` directory. | +## Version control + +Jujutsu terms, and how they differ from the Git and T3 Code terms above. See +[Jujutsu](./jujutsu.md). + +| Term | Meaning | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Workspace (jj) | A Jujutsu working copy with its own `@`, sharing one repository. Threads use one instead of a worktree in a Jujutsu project. Unrelated to T3 Code's own workspace root. | +| Bookmark | A named pointer to a change, and the same ref as a Git branch (`refs/heads/`) in a colocated repository. Unlike a branch it does not follow new commits. | +| Change | A Jujutsu commit, with a change id stable across rewrites. `@` is the working-copy change; `@-` is what Git calls `HEAD`. | + ## Orchestration | Term | Meaning | diff --git a/docs/internals/jujutsu.md b/docs/internals/jujutsu.md new file mode 100644 index 000000000000..276131c69955 --- /dev/null +++ b/docs/internals/jujutsu.md @@ -0,0 +1,98 @@ +# Jujutsu + +Why the jj lane looks the way it does. The actual commands, templates and revsets live in +[`apps/server/src/vcs`](../../apps/server/src/vcs) and [`apps/server/src/jj`](../../apps/server/src/jj), +this page is just the decisions you can't work out from one file. + +## jj owns the working copy, Git owns the object store + +In a colocated repo every jj commit, `@` included, is a real Git object in +`/.git` pinned by `refs/jj/keep/`. So the split is fixed: + +- Snapshot and restore are jj only. Git can't see `@` (`git rev-parse HEAD` is `@-`), and + `git restore` or `git clean` desyncs jj from disk. +- Every diff byte is Git plumbing with `--git-dir` at that store. The client diff parsers expect + `git diff` output and jj has no numstat, so [`Diffs.ts`](../../apps/server/src/checkpointing/Diffs.ts) + and the review pipeline stay untouched instead of growing a second parser. +- PRs, hosting CLIs and per-thread merge-base config keep running against the same `.git` from the + main workspace root, since they read `.git/config` themselves. A bookmark is `refs/heads/` + in that store after an explicit `jj git export`; secondary workspaces do not keep named Git refs + synchronized automatically. History and diff reads resolve names through jj first and pass commit + IDs to Git instead. + +Hence the scope limit: a jj repo with no reachable `.git` is detected as jj and reported unusable, +never quietly treated as Git. [`JjRepo.resolveJjRepoPaths`](../../apps/server/src/vcs/JjRepo.ts) +follows a secondary workspace's `.jj/repo` pointer back to the main root so every Git call still has +a `--git-dir`. + +## Detection stops at the nearest marker + +[`findVcsMarkerRoot`](../../apps/server/src/vcs/JjRepo.ts) walks up and stops at the first `.jj` or +`.git`, nearest wins. A `.jj` a few levels up must not hijack a nearer Git checkout. + +That's also what makes the flip safe for existing repos. A thread environment made before jj support +is a Git worktree (`.git` file, no `.jj`) and keeps resolving as Git against the same colocated +store. Workspace recovery and removal in +[`GitWorkflowService`](../../apps/server/src/git/GitWorkflowService.ts) resolve the workspace's owner +before pruning registrations. Ownership survives directory removal in shared Git config, keyed by +canonical workspace path; a missing legacy workspace with neither registration nor ownership record +is ambiguous and must not be recreated using the project's current backend. + +The walk returns `null` for a missing directory, so a deleted thread dir can't climb into some +unrelated repo above it. + +## Checkpoints are raw refs + +A checkpoint is the commit id of `@` at the end of a turn, pinned with `git update-ref`. Everything +else fails somewhere: bookmarks and tags show up in the ref picker and `git branch`, `jj commit` moves +`@` and drops a visible commit in the user's log every turn, and op ids stop resolving once the op +log is pruned. A ref under `refs/t3/` is durable, invisible and moves nothing, and it's what Git +checkpoints already write, so +[`checkpointRefForThreadTurn`](../../apps/server/src/checkpointing/Utils.ts) stays shared. + +What it doesn't give you is jj addressability. After an op log prune the object is still pinned in +Git but jj has forgotten it, so `jj restore --from ` fails with `Revision ... doesn't exist`. +Restore recovers by writing a temp `refs/heads/t3-restore-`, running `jj git import`, retrying, +then deleting both. See [`JjCheckpoints.ts`](../../apps/server/src/vcs/JjCheckpoints.ts). + +Capture pins one snapshot read and inspects uncaptured paths using a private Git index. Cleanup +checks ignored local data separately: checkpoint coverage alone cannot justify deleting a directory. +Committed unbookmarked work must move onto the workspace's recreation bookmark before deregistration. +Nothing in T3 Code may run +`jj abandon`, `jj op abandon`, `jj util gc`, `git reset`, `git clean` or `git restore` in a jj repo. +Restore is `jj restore`, so change id, description, bookmarks and other workspaces all survive, and +the user can `jj undo` it. + +## `@-` is HEAD, and root is never a revision + +Every fall-back-to-HEAD in checkpoints and review diffs means `@-`, never `@`. `jj restore` from +`@`'s own id prints `Nothing changed.` and exits 0, so you'd report a revert that did nothing. + +The root commit is the other trap. jj lists it as the parent of a first change and `trunk()` resolves +to it in a repo with no remote, and both hand Git an all-zeros id: `jj restore --from 0000...` empties +the working copy and `git diff 0000...` exits 128. So the change decoder in +[`JjVcsDriver.ts`](../../apps/server/src/vcs/JjVcsDriver.ts) drops root parents (a first change gets +`parentCommitIds: []`) and `changeAt` returns `null` for a revset that lands on root. That one `null` +covers default-bookmark, review-diff base and remote-tracking resolution. Treat the base as absent, +not as zero. + +## `git.abandon-unreachable-commits=false` on every command we run + +A colocated repo imports Git refs on every jj command that touches the working copy, and jj's default +is to abandon commits those refs moved off, rewriting files on disk. A plain `git fetch`, +`git branch -f` or `gh pr checkout` followed by the 30s status poll is enough to lose committed work. +The guard lives in the global arg prefix in [`JjProcess.ts`](../../apps/server/src/vcs/JjProcess.ts) +so every future call site inherits it. It's never applied to the user's own jj, their config is +theirs. + +## Unusable is not Git + +Missing `jj`, older than [`JJ_MINIMUM_VERSION`](../../apps/server/src/vcs/JjAvailability.ts), or not +colocated: detection still says jj. Falling back to Git would write a Git worktree into a jj repo. +Every op instead fails with a named reason that reaches clients as +`VcsStatusLocalResult.vcs.unsupportedReason` and disables the action menu with that text. + +Checkpoints are the exception. `checkpointsUsable` answers `false` there so +[`CheckpointStore.supportsCheckpoints`](../../apps/server/src/checkpointing/CheckpointStore.ts) skips +capture silently, same as a directory with no repo, instead of writing a failed-capture activity into +every turn. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 408810571632..1a4459e8ef60 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -135,6 +135,53 @@ server, but the host's own site will not show them, and the count reads **viewed The **Code** tab is a web and desktop surface. The mobile app reports a pull request's status but does not show its diff, so marks are made and read on web and desktop. +## Jujutsu repositories + +T3 Code works with [Jujutsu](https://jj-vcs.github.io/jj/) repositories that are colocated with Git, +which is what `jj git init --colocate` creates. Pull requests, cloning, and publishing still run +against the Git store in that repository, so a Jujutsu project gets the same hosting integration as a +Git one. Install Jujutsu 0.42.0 or newer on the machine running the server. + +Terms follow the repository: bookmarks instead of branches, workspaces instead of worktrees, changes +instead of commits. Checkpoints, turn diffs, and revert work the same way. A revert restores your +working copy with `jj restore`, so `jj undo` takes it back. + +### Choosing the version control system + +T3 Code uses the nearest marker directory, so a checkout holding `.jj` is Jujutsu and a Git checkout +inside it is still Git. Threads that already run in a Git worktree keep working as Git. + +To pin a project, add `.t3code/vcs.json` in its root: + +```json +{ "vcs": { "kind": "git" } } +``` + +Use `"jj"` to force Jujutsu, or delete the file to go back to detection. For a directory with no +version control yet, **Initialize Jujutsu** sits alongside **Initialize Git** and runs +`jj git init --colocate`. +For an existing Git repository, choose **Enable Jujutsu** from the repository actions on web, +desktop, or mobile. It keeps the Git history, remotes, and working files while adding a colocated +Jujutsu workspace. Run it from the main checkout; Git worktrees continue to use Git. + +### What is different under Jujutsu + +- **Git hooks do not run** for commits made through T3 Code. Hooks that read the Git index, such as + `lint-staged` or a staged-diff format check, see nothing under Jujutsu, so T3 Code skips them + rather than reporting a hook run that did nothing. Run them yourself if you depend on them. +- A change with conflicts cannot be pushed until you resolve it, and its diff shows one side of each + conflicted file rather than the markers on disk. +- T3 Code snapshots new files up to **10 MiB**. Unexpected files outside a checkpoint are reported + in the thread's activity; ignored files are excluded normally. Reverting a turn leaves excluded + files on disk. Files already tracked remain covered when they grow beyond that limit. +- A repository that is not colocated, a missing `jj`, or a version below 0.42.0 leaves source control + actions disabled with the reason, instead of silently falling back to Git. Install or upgrade + Jujutsu, or run `jj git init --colocate` in the repository. + +Agents receive a short Jujutsu hint for their current workspace. Turn off **VCS agent hints** in +**Settings → Source Control** to stop injecting these hints, including when resuming existing threads. +This setting can also be overridden for a project. + ## Troubleshooting - **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket, diff --git a/packages/client-runtime/src/state/gitActions.test.ts b/packages/client-runtime/src/state/gitActions.test.ts new file mode 100644 index 000000000000..d3cc987b51c8 --- /dev/null +++ b/packages/client-runtime/src/state/gitActions.test.ts @@ -0,0 +1,172 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; +import { assert, describe, it } from "vite-plus/test"; + +import { GIT_VCS_TERMINOLOGY, getVcsTerminology } from "@t3tools/shared/vcs"; + +import { buildMenuItems, getGitActionDisabledReason, resolveQuickAction } from "./gitActions.ts"; + +function status(overrides: Partial = {}): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/test", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + ...overrides, + }; +} + +describe("the folded web behaviour", () => { + it("explains a disabled request using the default-branch delta and behind state", () => { + const item = { + id: "pr", + label: "Pull request", + disabled: true, + icon: "pr", + kind: "open_dialog", + } as const; + const reason = (gitStatus: VcsStatusResult) => + getGitActionDisabledReason({ item, gitStatus, isBusy: false, hasPrimaryRemote: true }); + assert.equal( + reason(status({ aheadCount: 0, aheadOfDefaultCount: 3, behindCount: 1 })), + "Branch is behind upstream. Pull/rebase before creating a pull request.", + ); + assert.equal( + reason(status({ aheadCount: 3, aheadOfDefaultCount: 0 })), + "No local commits to include in a pull request.", + ); + }); + it("gates the change-request item on aheadOfDefaultCount, not aheadCount", () => { + const items = buildMenuItems(status({ aheadCount: 0, aheadOfDefaultCount: 3 }), false); + assert.isFalse(items.find((item) => item.id === "pr")?.disabled); + }); + + it("offers only the commit item when the repository has no primary remote", () => { + const items = buildMenuItems( + status({ hasWorkingTreeChanges: true, hasUpstream: false }), + false, + false, + ); + assert.deepEqual( + items.map((item) => item.id), + ["commit"], + ); + }); + + it("keeps push available while the working tree is dirty", () => { + const items = buildMenuItems(status({ hasWorkingTreeChanges: true, aheadCount: 1 }), false); + assert.isFalse(items.find((item) => item.id === "push")?.disabled); + }); +}); + +describe("the publish quick action", () => { + const remoteless = status({ hasUpstream: false, hasPrimaryRemote: false, aheadCount: 1 }); + + it("opens the publish flow only when the surface can publish", () => { + assert.equal(resolveQuickAction(remoteless, false, false, false, true).kind, "open_publish"); + }); + + it("never publishes by default, so mobile gets a hint instead of a dead button", () => { + const quick = resolveQuickAction(remoteless, false, false, false); + assert.equal(quick.kind, "show_hint"); + assert.isTrue(quick.disabled); + assert.equal(quick.hint, 'Add an "origin" remote before pushing or creating a pull request.'); + }); +}); + +describe("VCS terminology", () => { + const jj = { kind: "jj" } as const; + + it("keeps Git nouns when the status carries no vcs field", () => { + assert.equal( + resolveQuickAction(status({ refName: null }), false).hint, + "Create and check out a branch before pushing or opening a pull request.", + ); + assert.equal( + getGitActionDisabledReason({ + item: { id: "push", label: "Push", disabled: true, icon: "push", kind: "open_dialog" }, + gitStatus: status({ refName: null }), + isBusy: false, + hasPrimaryRemote: true, + }), + "Detached HEAD: check out a branch before pushing.", + ); + assert.equal(getVcsTerminology("unknown"), GIT_VCS_TERMINOLOGY); + assert.equal(getVcsTerminology(null), GIT_VCS_TERMINOLOGY); + }); + + it("swaps to bookmark and change nouns under jj", () => { + assert.equal( + resolveQuickAction(status({ refName: null, vcs: jj }), false).hint, + "No bookmark here: create one before pushing or opening a pull request.", + ); + assert.equal(resolveQuickAction(status({ vcs: jj }), true).hint, "Jujutsu action in progress."); + assert.equal( + getGitActionDisabledReason({ + item: { id: "push", label: "Push", disabled: true, icon: "push", kind: "open_dialog" }, + gitStatus: status({ refName: null, vcs: jj }), + isBusy: false, + hasPrimaryRemote: true, + }), + "No bookmark here: create one before pushing.", + ); + assert.equal( + getGitActionDisabledReason({ + item: { + id: "commit", + label: "Commit", + disabled: true, + icon: "commit", + kind: "open_dialog", + }, + gitStatus: status({ vcs: jj }), + isBusy: false, + hasPrimaryRemote: true, + }), + "The working copy is clean. Make changes before committing.", + ); + }); +}); + +describe("an unsupported VCS", () => { + const unsupported = status({ + hasWorkingTreeChanges: true, + vcs: { kind: "jj", unsupportedReason: "jj 0.40.0 is older than the supported 0.42.0." }, + }); + + it("disables every menu item", () => { + assert.deepEqual( + buildMenuItems(unsupported, false).map((item) => item.disabled), + [true, true, true], + ); + }); + + it("surfaces the reason as the quick action hint", () => { + const quick = resolveQuickAction(unsupported, false); + assert.isTrue(quick.disabled); + assert.equal(quick.hint, "jj 0.40.0 is older than the supported 0.42.0."); + }); + + it("surfaces the reason as the per-item disabled reason", () => { + assert.equal( + getGitActionDisabledReason({ + item: { + id: "commit", + label: "Commit", + disabled: true, + icon: "commit", + kind: "open_dialog", + }, + gitStatus: unsupported, + isBusy: false, + hasPrimaryRemote: true, + }), + "jj 0.40.0 is older than the supported 0.42.0.", + ); + }); +}); diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index d3571e65d2e6..ea2de6b2de8b 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -3,6 +3,17 @@ import type { GitStackedAction, VcsStatusResult, } from "@t3tools/contracts"; +import { + DEFAULT_CHANGE_REQUEST_TERMINOLOGY, + getChangeRequestTerminology, + type ChangeRequestTerminology, +} from "@t3tools/shared/sourceControl"; +import { + DEFAULT_VCS_TERMINOLOGY, + resolveVcsTerminology, + resolveVcsUnsupportedReason, + type VcsTerminology, +} from "@t3tools/shared/vcs"; export type GitActionIconName = "commit" | "push" | "pr"; @@ -13,14 +24,14 @@ export interface GitActionMenuItem { label: string; disabled: boolean; icon: GitActionIconName; - kind: "open_dialog" | "open_pr"; + kind: "open_dialog"; dialogAction?: GitDialogAction; } export interface GitQuickAction { label: string; disabled: boolean; - kind: "run_action" | "run_pull" | "open_pr" | "show_hint"; + kind: "run_action" | "run_pull" | "open_publish" | "show_hint"; action?: GitStackedAction; hint?: string; } @@ -42,80 +53,111 @@ export type GitActionRequestInput = Pick< "action" | "commitMessage" | "featureBranch" | "filePaths" >; +function resolveChangeRequestTerminology( + gitStatus: VcsStatusResult | null, +): ChangeRequestTerminology { + return gitStatus?.sourceControlProvider + ? getChangeRequestTerminology(gitStatus.sourceControlProvider) + : DEFAULT_CHANGE_REQUEST_TERMINOLOGY; +} + +/** jj has no detached HEAD, so the "you are not on a ref" hint is phrased per system. */ +export function noRefHint(gitStatus: VcsStatusResult, vcs: VcsTerminology, tail: string): string { + return gitStatus.vcs?.kind === "jj" + ? `No ${vcs.refNoun} here: create one before ${tail}.` + : `Detached HEAD: check out a ${vcs.refNoun} before ${tail}.`; +} + export function buildMenuItems( gitStatus: VcsStatusResult | null, isBusy: boolean, - hasOriginRemote = true, + hasPrimaryRemote = true, ): GitActionMenuItem[] { if (!gitStatus) return []; + const terminology = resolveChangeRequestTerminology(gitStatus); + // An unusable VCS blocks every action, the same way a run in flight does. + const blocked = isBusy || resolveVcsUnsupportedReason(gitStatus) !== null; const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isBehind = gitStatus.behindCount > 0; - const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; - const canCommit = !isBusy && hasChanges; + const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; + const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream; + const canCommit = !blocked && hasChanges; const canPush = - !isBusy && + !blocked && hasBranch && - !hasChanges && !isBehind && gitStatus.aheadCount > 0 && (gitStatus.hasUpstream || canPushWithoutUpstream); const canCreatePr = - !isBusy && + !blocked && hasBranch && !hasChanges && !hasOpenPr && - gitStatus.aheadCount > 0 && + hasDefaultBranchDelta && !isBehind && (gitStatus.hasUpstream || canPushWithoutUpstream); - const canOpenPr = !isBusy && hasOpenPr; + + const commitItem: GitActionMenuItem = { + id: "commit", + label: "Commit", + disabled: !canCommit, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }; + + if (!hasPrimaryRemote) { + return [commitItem]; + } + + const pushItem: GitActionMenuItem = { + id: "push", + label: "Push", + disabled: !canPush, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }; + + // An open change request is surfaced by the standalone attribution row, so + // the menu offers no change-request entry at all while one is open. + if (hasOpenPr) { + return [commitItem, pushItem]; + } return [ + commitItem, + pushItem, { - id: "commit", - label: "Commit", - disabled: !canCommit, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: !canPush, - icon: "push", + id: "pr", + label: `Create ${terminology.shortLabel}`, + disabled: !canCreatePr, + icon: "pr", kind: "open_dialog", - dialogAction: "push", + dialogAction: "create_pr", }, - hasOpenPr - ? { - id: "pr", - label: "View PR", - disabled: !canOpenPr, - icon: "pr", - kind: "open_pr", - } - : { - id: "pr", - label: "Create PR", - disabled: !canCreatePr, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, ]; } export function resolveQuickAction( gitStatus: VcsStatusResult | null, isBusy: boolean, - isDefaultBranch = false, - hasOriginRemote = true, + isDefaultRef = false, + hasPrimaryRemote = true, + canPublish = false, ): GitQuickAction { + const vcs = resolveVcsTerminology(gitStatus); + if (isBusy) { - return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; + return { + label: "Commit", + disabled: true, + kind: "show_hint", + hint: `${vcs.systemName} action in progress.`, + }; } if (!gitStatus) { @@ -123,35 +165,45 @@ export function resolveQuickAction( label: "Commit", disabled: true, kind: "show_hint", - hint: "Git status is unavailable.", + hint: `${vcs.systemName} status is unavailable.`, }; } + const unsupportedReason = resolveVcsUnsupportedReason(gitStatus); + if (unsupportedReason !== null) { + return { label: "Commit", disabled: true, kind: "show_hint", hint: unsupportedReason }; + } + const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; + const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; const isBehind = gitStatus.behindCount > 0; const isDiverged = isAhead && isBehind; + const terminology = resolveChangeRequestTerminology(gitStatus); if (!hasBranch) { return { label: "Commit", disabled: true, kind: "show_hint", - hint: "Create and checkout a branch before pushing or opening a PR.", + hint: + gitStatus.vcs?.kind === "jj" + ? `No ${vcs.refNoun} here: create one before pushing or opening a ${terminology.singular}.` + : `Create and check out a ${vcs.refNoun} before pushing or opening a ${terminology.singular}.`, }; } if (hasChanges) { - if (!gitStatus.hasUpstream && !hasOriginRemote) { + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; } - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultRef) { return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; } return { - label: "Commit, push & PR", + label: `Commit, push & ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "commit_push_pr", @@ -159,20 +211,29 @@ export function resolveQuickAction( } if (!gitStatus.hasUpstream) { - if (!hasOriginRemote) { - if (hasOpenPr && !isAhead) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + if (!hasPrimaryRemote) { + if (canPublish) { + return { + label: "Publish repository", + disabled: false, + kind: "open_publish", + }; } return { label: "Push", disabled: true, kind: "show_hint", - hint: 'Add an "origin" remote before pushing or creating a PR.', + hint: `Add an "origin" remote before pushing or creating a ${terminology.singular}.`, }; } if (!isAhead) { if (hasOpenPr) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { + label: "Commit", + disabled: true, + kind: "show_hint", + hint: `${vcs.refNounTitle} is up to date. No action needed.`, + }; } return { label: "Push", @@ -181,16 +242,16 @@ export function resolveQuickAction( hint: "No local commits to push.", }; } - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultRef) { return { label: "Push", disabled: false, kind: "run_action", - action: isDefaultBranch ? "commit_push" : "push", + action: isDefaultRef ? "commit_push" : "push", }; } return { - label: "Push & create PR", + label: `Push & create ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "create_pr", @@ -199,10 +260,10 @@ export function resolveQuickAction( if (isDiverged) { return { - label: "Sync branch", + label: `Sync ${vcs.refNoun}`, disabled: true, kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", + hint: `${vcs.refNounTitle} has diverged from upstream. Rebase/merge first.`, }; } @@ -215,31 +276,47 @@ export function resolveQuickAction( } if (isAhead) { - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultRef) { return { label: "Push", disabled: false, kind: "run_action", - action: isDefaultBranch ? "commit_push" : "push", + action: isDefaultRef ? "commit_push" : "push", }; } return { - label: "Push & create PR", + label: `Push & create ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "create_pr", }; } + // An open change request is surfaced by the standalone attribution row in the + // details panel, so the action button rests in its disabled up-to-date state. if (hasOpenPr && gitStatus.hasUpstream) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { + label: "Commit", + disabled: true, + kind: "show_hint", + hint: `${vcs.refNounTitle} is up to date. No action needed.`, + }; + } + + if (hasDefaultBranchDelta && !isDefaultRef) { + return { + label: `Create ${terminology.shortLabel}`, + disabled: false, + kind: "run_action", + action: "create_pr", + }; } return { label: "Commit", disabled: true, kind: "show_hint", - hint: "Branch is up to date. No action needed.", + hint: `${vcs.refNounTitle} is up to date. No action needed.`, }; } @@ -247,37 +324,42 @@ export function getGitActionDisabledReason(input: { item: GitActionMenuItem; gitStatus: VcsStatusResult | null; isBusy: boolean; - hasOriginRemote: boolean; + hasPrimaryRemote: boolean; }): string | null { - const { item, gitStatus, isBusy, hasOriginRemote } = input; + const { item, gitStatus, isBusy, hasPrimaryRemote } = input; + const vcs = resolveVcsTerminology(gitStatus); if (!item.disabled) return null; - if (isBusy) return "Git action in progress."; - if (!gitStatus) return "Git status is unavailable."; + if (isBusy) return `${vcs.systemName} action in progress.`; + if (!gitStatus) return `${vcs.systemName} status is unavailable.`; + + const unsupportedReason = resolveVcsUnsupportedReason(gitStatus); + if (unsupportedReason !== null) return unsupportedReason; + const terminology = resolveChangeRequestTerminology(gitStatus); const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; + const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; const isBehind = gitStatus.behindCount > 0; if (item.id === "commit") { if (!hasChanges) { - return "Worktree is clean. Make changes before committing."; + return `The ${vcs.workingTreeNoun} is clean. Make changes before committing.`; } return "Commit is currently unavailable."; } if (item.id === "push") { if (!hasBranch) { - return "Detached HEAD: checkout a branch before pushing."; + return noRefHint(gitStatus, vcs, "pushing"); } if (hasChanges) { return "Commit or stash local changes before pushing."; } if (isBehind) { - return "Branch is behind upstream. Pull/rebase before pushing."; + return `${vcs.refNounTitle} is behind upstream. Pull/rebase before pushing.`; } - if (!gitStatus.hasUpstream && !hasOriginRemote) { + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return 'Add an "origin" remote before pushing.'; } if (!isAhead) { @@ -286,32 +368,29 @@ export function getGitActionDisabledReason(input: { return "Push is currently unavailable."; } - if (hasOpenPr) { - return "View PR is currently unavailable."; - } if (!hasBranch) { - return "Detached HEAD: checkout a branch before creating a PR."; + return noRefHint(gitStatus, vcs, `creating a ${terminology.singular}`); } if (hasChanges) { - return "Commit local changes before creating a PR."; + return `Commit local changes before creating a ${terminology.singular}.`; } - if (!gitStatus.hasUpstream && !hasOriginRemote) { - return 'Add an "origin" remote before creating a PR.'; - } - if (!isAhead) { - return "No local commits to include in a PR."; + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { + return `Add an "origin" remote before creating a ${terminology.singular}.`; } if (isBehind) { - return "Branch is behind upstream. Pull/rebase before creating a PR."; + return `${vcs.refNounTitle} is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; + } + if (!hasDefaultBranchDelta) { + return `No local commits to include in a ${terminology.singular}.`; } - return "Create PR is currently unavailable."; + return `Create ${terminology.shortLabel} is currently unavailable.`; } export function requiresDefaultBranchConfirmation( action: GitStackedAction, - isDefaultBranch: boolean, + isDefaultRef: boolean, ): boolean { - if (!isDefaultBranch) return false; + if (!isDefaultRef) return false; return ( action === "push" || action === "create_pr" || @@ -324,20 +403,24 @@ export function resolveDefaultBranchActionDialogCopy(input: { action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; + terminology?: ChangeRequestTerminology; + vcsTerminology?: VcsTerminology; }): DefaultBranchActionDialogCopy { const branchLabel = input.branchName; - const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; + const vcs = input.vcsTerminology ?? DEFAULT_VCS_TERMINOLOGY; + const suffix = ` on "${branchLabel}". You can continue on this ${vcs.refNoun} or create a feature ${vcs.refNoun} and run the same action there.`; + const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; if (input.action === "push" || input.action === "commit_push") { if (input.includesCommit) { return { - title: "Commit & push to default branch?", + title: `Commit & push to default ${vcs.refNoun}?`, description: `This action will commit and push changes${suffix}`, continueLabel: `Commit & push to ${branchLabel}`, }; } return { - title: "Push to default branch?", + title: `Push to default ${vcs.refNoun}?`, description: `This action will push local commits${suffix}`, continueLabel: `Push to ${branchLabel}`, }; @@ -345,14 +428,14 @@ export function resolveDefaultBranchActionDialogCopy(input: { if (input.includesCommit) { return { - title: "Commit, push & create PR from default branch?", - description: `This action will commit, push, and create a PR${suffix}`, - continueLabel: "Commit, push & create PR", + title: `Commit, push & create ${terminology.shortLabel} from default ${vcs.refNoun}?`, + description: `This action will commit, push, and create a ${terminology.singular}${suffix}`, + continueLabel: `Commit, push & create ${terminology.shortLabel}`, }; } return { - title: "Push & create PR from default branch?", - description: `This action will push local commits and create a PR${suffix}`, - continueLabel: "Push & create PR", + title: `Push & create ${terminology.shortLabel} from default ${vcs.refNoun}?`, + description: `This action will push local commits and create a ${terminology.singular}${suffix}`, + continueLabel: `Push & create ${terminology.shortLabel}`, }; } diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index c5a5825e193e..1eef3996f520 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -212,6 +212,18 @@ const VcsStatusChangeRequest = Schema.Struct({ const VcsStatusLocalShape = { isRepo: Schema.Boolean, + /** + * Version control system backing this workspace. Emitted only when it is not Git, so existing + * payloads are unchanged; absent means Git. `unsupportedReason` is set when the detected system + * is present but unusable (a non-colocated Jujutsu repository, or a `jj` below the supported + * minimum). Its presence is the signal and the string is the user-facing reason. + */ + vcs: Schema.optional( + Schema.Struct({ + kind: VcsDriverKind, + unsupportedReason: Schema.optional(TrimmedNonEmptyStringSchema), + }), + ), sourceControlProvider: Schema.optional(SourceControlProviderInfo), hasPrimaryRemote: Schema.Boolean, isDefaultRef: Schema.Boolean, @@ -288,7 +300,7 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({ branch: TrimmedNonEmptyStringSchema, worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), /** - * False when the checkout could not be brought to the pull request head — a reused worktree + * False when the checkout could not be brought to the pull request head. A reused worktree * holding local commits or uncommitted changes keeps its own state, so the code being handed * over is older than the pull request. */ diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 8753ac2cf8e8..a509d8740fd9 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -68,6 +68,8 @@ export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; export const ProviderSendTurnInput = Schema.Struct({ threadId: ThreadId, + /** Server-resolved context; null clears the hint on existing and resumed sessions. */ + vcsAgentHint: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), /** Internal recovery signal. Allows an empty turn only for adapters that explicitly support promptless continuation. */ continuation: Schema.optional(Schema.Boolean), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4ff337edecf9..c881b53512fc 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1031,6 +1031,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "worktreeSubmodules", + "enableVcsAgentHints", "defaultAutoPull", "defaultProjectScripts", "enableAgentBrowserAccess", @@ -1059,6 +1060,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules), + enableVcsAgentHints: Schema.optionalKey(Schema.Boolean), defaultAutoPull: Schema.optionalKey(Schema.Boolean), defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), @@ -1250,6 +1252,7 @@ export const ServerSettings = Schema.Struct({ ), worktreeDirectory: WorktreeDirectory.pipe(Schema.withDecodingDefault(Effect.succeed(""))), worktreeProjectFolders: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + enableVcsAgentHints: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), worktreeBranchPrefix: WorktreeBranchPrefix.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_WORKTREE_BRANCH_PREFIX)), ), @@ -1563,6 +1566,7 @@ export const ServerSettingsPatch = Schema.Struct({ worktreeProjectFolders: Schema.optionalKey(Schema.Boolean), worktreeBranchPrefix: Schema.optionalKey(WorktreeBranchPrefix), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), + enableVcsAgentHints: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( diff --git a/packages/shared/package.json b/packages/shared/package.json index acdb428d7ffe..268e02819421 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -51,6 +51,10 @@ "types": "./src/githubMedia.ts", "import": "./src/githubMedia.ts" }, + "./vcs": { + "types": "./src/vcs.ts", + "import": "./src/vcs.ts" + }, "./sourceControl": { "types": "./src/sourceControl.ts", "import": "./src/sourceControl.ts" diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 53d176ffa85d..e2768fb09198 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -1,4 +1,8 @@ -import type { VcsStatusRemoteResult, VcsStatusResult } from "@t3tools/contracts"; +import type { + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -273,12 +277,57 @@ describe("applyGitStatusStreamEvent", () => { pr: null, }; - expect(applyGitStatusStreamEvent(current, { _tag: "remoteUpdated", remote })).toEqual({ + const merged = applyGitStatusStreamEvent(current, { _tag: "remoteUpdated", remote }); + + expect(merged).toEqual({ ...current, hasUpstream: true, aheadCount: 2, behindCount: 1, pr: null, }); + expect(Object.keys(merged)).not.toContain("vcs"); + }); + + it("carries vcs through a local to remote to local sequence", () => { + const local: VcsStatusLocalResult = { + isRepo: true, + vcs: { kind: "jj", unsupportedReason: "Not a colocated Jujutsu repository." }, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }; + const remote: VcsStatusRemoteResult = { + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }; + + const afterLocal = applyGitStatusStreamEvent(null, { _tag: "localUpdated", local }); + const afterRemote = applyGitStatusStreamEvent(afterLocal, { _tag: "remoteUpdated", remote }); + const afterSecondLocal = applyGitStatusStreamEvent(afterRemote, { + _tag: "localUpdated", + local: { ...local, hasWorkingTreeChanges: true }, + }); + + expect(afterRemote.vcs).toEqual(local.vcs); + expect(afterSecondLocal.vcs).toEqual(local.vcs); + expect(afterSecondLocal.aheadCount).toBe(2); + }); + + it("omits vcs from the synthetic local part of a remote-only update", () => { + const remote: VcsStatusRemoteResult = { + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + + expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).not.toHaveProperty( + "vcs", + ); }); }); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 5c725ec7e09a..3b418441139a 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -24,7 +24,7 @@ const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp( const TEMP_WORKTREE_TOKEN_PATTERN = new RegExp(`^${TEMP_WORKTREE_TOKEN_SOURCE}$`); /** - * Sanitize an arbitrary string into a valid, lowercase git refName fragment. + * Sanitize an arbitrary string into a valid, lowercase git branch fragment. * Strips quotes, collapses separators, limits to 64 chars. */ export function sanitizeBranchFragment(raw: string): string { @@ -46,7 +46,7 @@ export function sanitizeBranchFragment(raw: string): string { } /** - * Sanitize a string into a `feature/…` refName name. + * Sanitize a string into a `feature/…` branch name. * Preserves an existing `feature/` prefix or slash-separated namespace. */ export function sanitizeFeatureBranchName(raw: string): string { @@ -60,8 +60,8 @@ export function sanitizeFeatureBranchName(raw: string): string { const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; /** - * Resolve a unique `feature/…` refName name that doesn't collide with - * any existing refName. Appends a numeric suffix when needed. + * Resolve a unique `feature/…` branch name that doesn't collide with + * any existing branch. Appends a numeric suffix when needed. */ export function resolveAutoFeatureBranchName( existingBranchNames: readonly string[], @@ -359,6 +359,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { return { isRepo: status.isRepo, + ...(status.vcs ? { vcs: status.vcs } : {}), ...(status.sourceControlProvider ? { sourceControlProvider: status.sourceControlProvider } : {}), diff --git a/packages/shared/src/vcs.ts b/packages/shared/src/vcs.ts new file mode 100644 index 000000000000..37d7ebce529e --- /dev/null +++ b/packages/shared/src/vcs.ts @@ -0,0 +1,70 @@ +import type { VcsDriverKind, VcsStatusLocalResult } from "@t3tools/contracts"; + +export interface VcsTerminology { + readonly systemName: string; + readonly refNoun: string; + readonly refNounPlural: string; + readonly refNounTitle: string; + readonly workspaceNoun: string; + readonly workspaceNounTitle: string; + readonly workspaceNounPlural: string; + /** "commit" | "change" */ + readonly changeNoun: string; + readonly changeNounTitle: string; + /** What to call "the tree you are editing": "working tree" | "working copy" */ + readonly workingTreeNoun: string; + readonly workingTreeNounTitle: string; + /** What to call "no ref here": "checkout" | "working copy" */ + readonly currentRefFallback: string; +} + +export const GIT_VCS_TERMINOLOGY: VcsTerminology = { + systemName: "Git", + refNoun: "branch", + refNounPlural: "branches", + refNounTitle: "Branch", + workspaceNoun: "worktree", + workspaceNounTitle: "Worktree", + workspaceNounPlural: "worktrees", + changeNoun: "commit", + changeNounTitle: "Commit", + workingTreeNoun: "working tree", + workingTreeNounTitle: "Working tree", + currentRefFallback: "checkout", +}; + +export const JJ_VCS_TERMINOLOGY: VcsTerminology = { + systemName: "Jujutsu", + refNoun: "bookmark", + refNounPlural: "bookmarks", + refNounTitle: "Bookmark", + workspaceNoun: "workspace", + workspaceNounTitle: "Workspace", + workspaceNounPlural: "workspaces", + changeNoun: "change", + changeNounTitle: "Change", + workingTreeNoun: "working copy", + workingTreeNounTitle: "Working copy", + currentRefFallback: "working copy", +}; + +export const DEFAULT_VCS_TERMINOLOGY = GIT_VCS_TERMINOLOGY; + +/** `null`/`undefined`/`"unknown"` resolve to Git terms so a transient status never flips copy. */ +export function getVcsTerminology(kind: VcsDriverKind | null | undefined): VcsTerminology { + return kind === "jj" ? JJ_VCS_TERMINOLOGY : GIT_VCS_TERMINOLOGY; +} + +/** The accessor every client surface uses; mirrors `resolveChangeRequestTerminology`. */ +export function resolveVcsTerminology( + status: Pick | null | undefined, +): VcsTerminology { + return getVcsTerminology(status?.vcs?.kind); +} + +/** Present only when the workspace's VCS is detected but unusable. */ +export function resolveVcsUnsupportedReason( + status: Pick | null | undefined, +): string | null { + return status?.vcs?.unsupportedReason ?? null; +}