From f14ab64d49f3fe5d633d8c1c6fb2cea2c1a5dd91 Mon Sep 17 00:00:00 2001 From: knocking4thcylinder Date: Sun, 27 Sep 2026 04:47:51 +0300 Subject: [PATCH] feat(git): add guided repository configuration --- .../features/threads/git/GitOverviewSheet.tsx | 171 ++++++++++++++++- apps/server/src/auth/RpcAuthorization.ts | 2 + apps/server/src/server.test.ts | 6 +- apps/server/src/server.ts | 2 + .../src/vcs/VcsConfigurationService.test.ts | 43 +++++ .../server/src/vcs/VcsConfigurationService.ts | 162 ++++++++++++++++ apps/server/src/ws.ts | 10 + .../settings/SourceControlSettings.tsx | 2 + .../settings/VcsConfigurationSettings.tsx | 180 ++++++++++++++++++ docs/user/source-control.md | 6 + packages/client-runtime/src/state/vcs.ts | 11 ++ packages/contracts/src/git.ts | 23 +++ packages/contracts/src/rpc.ts | 18 ++ 13 files changed, 626 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/vcs/VcsConfigurationService.test.ts create mode 100644 apps/server/src/vcs/VcsConfigurationService.ts create mode 100644 apps/web/src/components/settings/VcsConfigurationSettings.tsx diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 644b23fa0c31..63aea53c3288 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -9,7 +9,12 @@ import { resolveThreadPullRequestChains, threadPullRequestKeyOf, } from "@t3tools/shared/threadPullRequests"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + ThreadId, + type VcsConfigurationResult, + type VcsConfigurationWriteInput, +} from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -22,7 +27,15 @@ import { } from "@react-navigation/native"; import { SymbolView } from "../../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { + Alert, + Platform, + Pressable, + RefreshControl, + ScrollView, + TextInput, + View, +} from "react-native"; import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -41,17 +54,69 @@ import { } from "../../../native/StackHeader"; import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; import { useEnvironmentQuery } from "../../../state/query"; +import { useAtomCommand } from "../../../state/use-atom-command"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; 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"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); +function ConfigurationField({ + label, + detail, + entry, + setting, + disabled, + onWrite, +}: { + label: string; + detail: string; + entry: VcsConfigurationResult["userName"]; + setting: VcsConfigurationWriteInput["setting"]; + disabled: boolean; + onWrite: (setting: VcsConfigurationWriteInput["setting"], value: string | null) => void; +}) { + const [value, setValue] = useState(entry.repository ?? entry.effective ?? ""); + return ( + + {label} + {detail} + + + {entry.repository !== null + ? "Repository override" + : entry.effective !== null + ? `Inherited: ${entry.effective}` + : "Unset"} + + + onWrite(setting, value)} + > + Save + + {entry.repository !== null ? ( + onWrite(setting, null)}> + Reset + + ) : null} + + + ); +} + type GitOverviewSheetProps = StaticScreenProps<{ readonly environmentId: string; readonly threadId: string; @@ -81,12 +146,6 @@ 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"]; - const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null ? vcsEnvironment.status({ @@ -95,6 +154,28 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { }) : null, ); + const [configurationOpen, setConfigurationOpen] = useState(false); + const [configurationSaving, setConfigurationSaving] = useState(false); + const configuration = useEnvironmentQuery( + configurationOpen && + selectedThread !== null && + selectedThreadCwd !== null && + gitStatus.data?.isRepo === true && + gitStatus.data.vcs?.kind !== "jj" + ? vcsEnvironment.configuration({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); + const writeConfiguration = useAtomCommand(vcsEnvironment.writeConfiguration, { + reportFailure: false, + }); + 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"]; const vcsTerminology = resolveVcsTerminology(gitStatus.data); const currentBranchLabel = @@ -103,6 +184,28 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const currentWorktreePath = selectedThreadWorktreePath; const gitOperationLabel = gitState.gitOperationLabel; const busy = gitOperationLabel !== null; + const onWriteConfiguration = useCallback( + (setting: VcsConfigurationWriteInput["setting"], value: string | null) => { + if (selectedThread === null || selectedThreadCwd === null) return; + setConfigurationSaving(true); + void writeConfiguration({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd, setting, value }, + }).then((result) => { + setConfigurationSaving(false); + if (result._tag === "Success") { + configuration.refresh(); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not update repository configuration", + error instanceof Error ? error.message : "An error occurred.", + ); + } + }); + }, + [configuration, selectedThread, selectedThreadCwd, writeConfiguration], + ); const isRepo = gitStatus.data?.isRepo ?? true; const hasPrimaryRemote = gitStatus.data?.hasPrimaryRemote ?? false; const isDefaultRef = gitStatus.data?.isDefaultRef ?? false; @@ -345,6 +448,56 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { }) } /> + {isRepo && gitStatus.data?.vcs?.kind !== "jj" ? ( + <> + {Platform.OS !== "android" ? : null} + setConfigurationOpen((open) => !open)} + /> + {configurationOpen ? ( + configuration.error ? ( + {configuration.error} + ) : configuration.data ? ( + <> + + + + + ) : ( + + Loading configuration... + + ) + ) : null} + + ) : null} {linkedPrChains.length > 0 ? ( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 8ab1520a6f34..5dd87dd14352 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -134,6 +134,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope, [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope, [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsConfigurationRead]: AuthOrchestrationReadScope, + [WS_METHODS.vcsConfigurationWrite]: AuthOrchestrationOperateScope, [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8fa0f69d1df0..6b69abfa311b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -171,6 +171,7 @@ import * as VcsDriver from "./vcs/VcsDriver.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as VcsConfigurationService from "./vcs/VcsConfigurationService.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; @@ -739,6 +740,9 @@ const buildAppUnderTest = (options?: { const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( Layer.provide(vcsDriverRegistryLayer), ); + const vcsConfigurationLayer = VcsConfigurationService.layer.pipe( + Layer.provide(vcsDriverRegistryLayer), + ); const reviewLayer = options?.layers?.reviewService ? Layer.mock(ReviewService.ReviewService)({ ...options.layers.reviewService, @@ -937,7 +941,7 @@ const buildAppUnderTest = (options?: { Layer.provide(gitVcsDriverLayer), Layer.provide(gitWorkflowLayer), Layer.provide(reviewLayer), - Layer.provide(vcsProvisioningLayer), + Layer.provide(Layer.merge(vcsProvisioningLayer, vcsConfigurationLayer)), Layer.provide( Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ ...options?.layers?.sourceControlRepositoryService, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2e6acdadf69d..560a94e0222a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -109,6 +109,7 @@ import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as VcsConfigurationService from "./vcs/VcsConfigurationService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; @@ -405,6 +406,7 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge(VcsProjectConfig.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), + Layer.provideMerge(VcsConfigurationService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), Layer.provideMerge(GitWorkflowLayerLive), Layer.provideMerge(ReviewLayerLive), Layer.provideMerge(SourceControlRepositoryServiceLayerLive), diff --git a/apps/server/src/vcs/VcsConfigurationService.test.ts b/apps/server/src/vcs/VcsConfigurationService.test.ts new file mode 100644 index 000000000000..a5ebce843c80 --- /dev/null +++ b/apps/server/src/vcs/VcsConfigurationService.test.ts @@ -0,0 +1,43 @@ +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 VcsConfigurationService from "./VcsConfigurationService.ts"; +import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; +import * as VcsProcess from "./VcsProcess.ts"; +import { runGit } from "./testing/JjTestSupport.ts"; + +const TestLayer = VcsConfigurationService.layer.pipe( + Layer.provide(VcsDriverRegistry.layer), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +it.effect("reads, writes, and resets guided Git repository configuration", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-config-" }); + yield* runGit(root, ["init", "--initial-branch=main"]); + const configuration = yield* VcsConfigurationService.VcsConfigurationService; + + yield* configuration.write({ cwd: root, setting: "userName", value: "Repository Author" }); + yield* configuration.write({ cwd: root, setting: "largeFile", value: "4 MiB" }); + const saved = yield* configuration.read({ cwd: root }); + assert.equal(saved.kind, "git"); + assert.equal(saved.userName.repository, "Repository Author"); + assert.equal(saved.largeFile.repository, "4m"); + assert.equal( + (yield* runGit(root, ["config", "--local", "--get", "core.bigFileThreshold"])).trim(), + "4m", + ); + + yield* configuration.write({ cwd: root, setting: "largeFile", value: null }); + assert.equal((yield* configuration.read({ cwd: root })).largeFile.repository, null); + const failure = yield* configuration + .write({ cwd: root, setting: "largeFile", value: "5000" }) + .pipe(Effect.flip); + assert.equal(failure._tag, "VcsUnsupportedOperationError"); + }).pipe(Effect.provide(TestLayer)), +); diff --git a/apps/server/src/vcs/VcsConfigurationService.ts b/apps/server/src/vcs/VcsConfigurationService.ts new file mode 100644 index 000000000000..a9f10a63f437 --- /dev/null +++ b/apps/server/src/vcs/VcsConfigurationService.ts @@ -0,0 +1,162 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + type VcsConfigurationInput, + type VcsConfigurationResult, + type VcsConfigurationWriteInput, + type VcsError, + VcsProcessExitError, + VcsUnsupportedOperationError, +} from "@t3tools/contracts"; +import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; + +type Setting = VcsConfigurationWriteInput["setting"]; +type Handle = VcsDriverRegistry.VcsDriverHandle; + +const CONFIG_KEYS: Record = { + userName: "user.name", + userEmail: "user.email", + largeFile: "core.bigFileThreshold", +}; + +function validateValue( + setting: Setting, + value: string, +): Effect.Effect { + const trimmed = value.trim(); + if (setting === "largeFile") { + const match = /^([1-9]\d{0,3})(?:\s*(?:m|mib))?$/i.exec(trimmed); + const mebibytes = match ? Number(match[1]) : 0; + if (mebibytes >= 1 && mebibytes <= 4096) { + return Effect.succeed(`${mebibytes}m`); + } + return Effect.fail( + new VcsUnsupportedOperationError({ + operation: "VcsConfigurationService.write", + kind: "git", + detail: "Enter a large-file threshold between 1 and 4096 MiB.", + }), + ); + } + const hasControlCharacter = [...trimmed].some((character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127; + }); + if (trimmed.length > 0 && trimmed.length <= 256 && !hasControlCharacter) { + return Effect.succeed(trimmed); + } + return Effect.fail( + new VcsUnsupportedOperationError({ + operation: "VcsConfigurationService.write", + kind: "git", + detail: "Enter a name or email without control characters (up to 256 characters).", + }), + ); +} + +export class VcsConfigurationService extends Context.Service< + VcsConfigurationService, + { + readonly read: ( + input: VcsConfigurationInput, + ) => Effect.Effect; + readonly write: (input: VcsConfigurationWriteInput) => Effect.Effect; + } +>()("t3/vcs/VcsConfigurationService") {} + +export const make = Effect.gen(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + + const readValue = Effect.fn("VcsConfigurationService.readValue")(function* ( + handle: Handle, + cwd: string, + setting: Setting, + repositoryOnly: boolean, + ) { + const key = CONFIG_KEYS[setting]; + const args = ["config", ...(repositoryOnly ? ["--local"] : []), "--get", key]; + const result = yield* handle.driver.execute({ + operation: "VcsConfigurationService.read", + cwd, + args, + allowNonZeroExit: true, + maxOutputBytes: 4_096, + }); + if (result.exitCode === 1) return null; + if (result.exitCode !== 0) { + return yield* new VcsProcessExitError({ + operation: "VcsConfigurationService.read", + command: "git config", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "Could not read repository configuration.", + }); + } + if (result.stdout.trim() === "") return null; + return result.stdout.trim(); + }); + + const read: VcsConfigurationService["Service"]["read"] = Effect.fn( + "VcsConfigurationService.read", + )(function* (input) { + const handle = yield* registry.resolve({ cwd: input.cwd }); + if (handle.kind !== "git") { + return yield* new VcsUnsupportedOperationError({ + operation: "VcsConfigurationService.read", + kind: handle.kind, + detail: "Repository configuration is available for Git only.", + }); + } + const entry = (setting: Setting) => + Effect.all({ + effective: readValue(handle, input.cwd, setting, false), + repository: readValue(handle, input.cwd, setting, true), + }); + const values = yield* Effect.all({ + userName: entry("userName"), + userEmail: entry("userEmail"), + largeFile: entry("largeFile"), + }); + return { kind: handle.kind, ...values } satisfies VcsConfigurationResult; + }); + + const write: VcsConfigurationService["Service"]["write"] = Effect.fn( + "VcsConfigurationService.write", + )(function* (input) { + const handle = yield* registry.resolve({ cwd: input.cwd }); + if (handle.kind !== "git") { + return yield* new VcsUnsupportedOperationError({ + operation: "VcsConfigurationService.write", + kind: handle.kind, + detail: "Repository configuration is available for Git only.", + }); + } + const key = CONFIG_KEYS[input.setting]; + if ( + input.value === null && + (yield* readValue(handle, input.cwd, input.setting, true)) === null + ) { + return; + } + const value = input.value === null ? null : yield* validateValue(input.setting, input.value); + const args = [ + "config", + "--local", + value === null ? "--unset-all" : "--replace-all", + key, + ...(value === null ? [] : [value]), + ]; + yield* handle.driver.execute({ + operation: "VcsConfigurationService.write", + cwd: input.cwd, + args, + maxOutputBytes: 4_096, + }); + }); + + return VcsConfigurationService.of({ read, write }); +}); + +export const layer = Layer.effect(VcsConfigurationService, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7051e49b1914..fd2a3cfa2adb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -136,6 +136,7 @@ import { readWorkflowScript } from "./orchestration/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as VcsConfigurationService from "./vcs/VcsConfigurationService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; @@ -555,6 +556,7 @@ const makeWsRpcLayer = ( const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; + const vcsConfiguration = yield* VcsConfigurationService.VcsConfigurationService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const terminalManager = yield* TerminalManager.TerminalManager; const previewManager = yield* PreviewManager.PreviewManager; @@ -3409,6 +3411,14 @@ const makeWsRpcLayer = ( .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), + [WS_METHODS.vcsConfigurationRead]: (input) => + observeRpcEffect(WS_METHODS.vcsConfigurationRead, vcsConfiguration.read(input), { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.vcsConfigurationWrite]: (input) => + observeRpcEffect(WS_METHODS.vcsConfigurationWrite, vcsConfiguration.write(input), { + "rpc.aggregate": "vcs", + }), [WS_METHODS.reviewGetDiffPreview]: (input) => observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), { "rpc.aggregate": "review", diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 84b2f7699f8a..c88e99c052fa 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -58,6 +58,7 @@ import { } from "../Icons"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; +import { VcsConfigurationSettingsSection } from "./VcsConfigurationSettings"; import { PolicyTooltip, SettingResetButton, @@ -594,6 +595,7 @@ export function SourceControlSettingsPanel() { )} + ); } diff --git a/apps/web/src/components/settings/VcsConfigurationSettings.tsx b/apps/web/src/components/settings/VcsConfigurationSettings.tsx new file mode 100644 index 000000000000..69b712e778a0 --- /dev/null +++ b/apps/web/src/components/settings/VcsConfigurationSettings.tsx @@ -0,0 +1,180 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { VcsConfigurationResult, VcsConfigurationWriteInput } from "@t3tools/contracts"; +import { useState } from "react"; + +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { vcsEnvironment } from "../../state/vcs"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { toastManager } from "../ui/toast"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { useSettingsScope } from "./SettingsScopeContext"; + +type Setting = VcsConfigurationWriteInput["setting"]; +type Entry = VcsConfigurationResult["userName"]; + +function ConfigurationRow({ + setting, + title, + description, + entry, + disabled, + onWrite, +}: { + setting: Setting; + title: string; + description: string; + entry: Entry; + disabled: boolean; + onWrite: (setting: Setting, value: string | null) => Promise; +}) { + const [value, setValue] = useState(entry.repository ?? entry.effective ?? ""); + const hasOverride = entry.repository !== null; + return ( + + setValue(event.target.value)} + disabled={disabled} + /> + + {hasOverride ? ( + + ) : null} + + } + /> + ); +} + +export function VcsConfigurationSettingsSection() { + const { scope } = useSettingsScope(); + const member = + (scope.kind === "project" || scope.kind === "checkout") && scope.members.length === 1 + ? scope.members[0]! + : null; + const status = useEnvironmentQuery( + member === null + ? null + : vcsEnvironment.status({ + environmentId: member.environmentId, + input: { cwd: member.workspaceRoot }, + }), + ); + const configuration = useEnvironmentQuery( + member === null || status.data?.isRepo !== true || status.data.vcs?.kind === "jj" + ? null + : vcsEnvironment.configuration({ + environmentId: member.environmentId, + input: { cwd: member.workspaceRoot }, + }), + ); + const writeConfiguration = useAtomCommand(vcsEnvironment.writeConfiguration, { + reportFailure: false, + }); + const [saving, setSaving] = useState(false); + if (scope.kind !== "project" && scope.kind !== "checkout") return null; + + const onWrite = async (setting: Setting, value: string | null) => { + if (!member) return; + setSaving(true); + try { + const result = await writeConfiguration({ + environmentId: member.environmentId, + input: { cwd: member.workspaceRoot, setting, value }, + }); + if (result._tag === "Success") { + configuration.refresh(); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not update repository configuration", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + } finally { + setSaving(false); + } + }; + + const config = configuration.data; + if (status.data?.vcs?.kind === "jj") return null; + return ( + + {member === null ? ( +

+ Choose one Git checkout to edit its repository configuration. +

+ ) : configuration.error ? ( +

{configuration.error}

+ ) : !config ? ( +

+ Loading repository configuration... +

+ ) : ( + <> + + + + + )} +
+ ); +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 599a22e4e29f..2e65661b42ef 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -164,6 +164,12 @@ For an existing Git repository, choose **Enable Jujutsu** from the repository ac 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. +To configure a Git checkout on web or desktop, select it in **Settings → Source control → Repository +configuration**. On mobile, open **Repository configuration** from the repository sheet. +The guided controls set its commit author, email, and large-file diff threshold. Git treats files +above that threshold as binary in diffs; checkpoints still include them. Reset removes a repository +override and returns to the user's Git configuration. + ### What is different under Jujutsu - **Git hooks do not run** for commits made through T3 Code. Hooks that read the Git index, such as diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index bedcb751216f..39e05a8f3eeb 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -17,6 +17,7 @@ import { Atom, AtomRegistry } from "effect/unstable/reactivity"; import { createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, createEnvironmentSubscriptionAtomFamily, } from "./runtime.ts"; @@ -279,6 +280,16 @@ export function createVcsEnvironmentAtoms( }); return { + configuration: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:configuration", + tag: WS_METHODS.vcsConfigurationRead, + }), + writeConfiguration: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:write-configuration", + tag: WS_METHODS.vcsConfigurationWrite, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), listRefs, status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 1eef3996f520..5f6296c814eb 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -191,6 +191,29 @@ export const VcsInitInput = Schema.Struct({ }); export type VcsInitInput = typeof VcsInitInput.Type; +export const VcsConfigurationInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema }); +export type VcsConfigurationInput = typeof VcsConfigurationInput.Type; + +const VcsConfigurationValue = Schema.Struct({ + effective: Schema.NullOr(Schema.String), + repository: Schema.NullOr(Schema.String), +}); + +export const VcsConfigurationResult = Schema.Struct({ + kind: VcsDriverKind, + userName: VcsConfigurationValue, + userEmail: VcsConfigurationValue, + largeFile: VcsConfigurationValue, +}); +export type VcsConfigurationResult = typeof VcsConfigurationResult.Type; + +export const VcsConfigurationWriteInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + setting: Schema.Literals(["userName", "userEmail", "largeFile"]), + value: Schema.NullOr(Schema.String), +}); +export type VcsConfigurationWriteInput = typeof VcsConfigurationWriteInput.Type; + // RPC Results const VcsStatusChangeRequest = Schema.Struct({ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index dbc143048a72..9f05703e7f43 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -64,6 +64,9 @@ import { VcsCreateWorktreeInput, VcsCreateWorktreeResult, VcsInitInput, + VcsConfigurationInput, + VcsConfigurationResult, + VcsConfigurationWriteInput, VcsListRefsInput, VcsListRefsResult, GitManagerServiceError, @@ -319,6 +322,8 @@ export const WS_METHODS = { vcsCreateRef: "vcs.createRef", vcsSwitchRef: "vcs.switchRef", vcsInit: "vcs.init", + vcsConfigurationRead: "vcs.configuration.read", + vcsConfigurationWrite: "vcs.configuration.write", // Git workflow methods gitRunStackedAction: "git.runStackedAction", @@ -1091,6 +1096,17 @@ const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { error: Schema.Union([VcsError, EnvironmentAuthorizationError]), }); +const WsVcsConfigurationReadRpc = Rpc.make(WS_METHODS.vcsConfigurationRead, { + payload: VcsConfigurationInput, + success: VcsConfigurationResult, + error: Schema.Union([VcsError, EnvironmentAuthorizationError]), +}); + +const WsVcsConfigurationWriteRpc = Rpc.make(WS_METHODS.vcsConfigurationWrite, { + payload: VcsConfigurationWriteInput, + error: Schema.Union([VcsError, EnvironmentAuthorizationError]), +}); + /** * Ephemeral live diff preview for compact/mobile surfaces. * Not the persisted T3 Review model. Future review sessions should use @@ -1491,6 +1507,8 @@ export const WsRpcGroup = RpcGroup.make( WsVcsCreateRefRpc, WsVcsSwitchRefRpc, WsVcsInitRpc, + WsVcsConfigurationReadRpc, + WsVcsConfigurationWriteRpc, WsReviewGetDiffPreviewRpc, WsReviewGetDiffFileContentsRpc, WsTerminalOpenRpc,