From 76e1260959988b1364d8b705cf2fc5bf24811a3e Mon Sep 17 00:00:00 2001 From: knocking4thcylinder Date: Sun, 27 Sep 2026 04:49:13 +0300 Subject: [PATCH] feat(jj): add guided repository configuration --- .../features/threads/git/GitOverviewSheet.tsx | 20 ++-- .../src/vcs/VcsConfigurationService.test.ts | 29 +++++- .../server/src/vcs/VcsConfigurationService.ts | 94 ++++++++++++++----- .../settings/VcsConfigurationSettings.tsx | 22 ++--- docs/user/source-control.md | 13 +-- 5 files changed, 124 insertions(+), 54 deletions(-) diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 63aea53c3288..dc64ca5c53ed 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -157,11 +157,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { 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" + configurationOpen && selectedThread !== null && selectedThreadCwd !== null ? vcsEnvironment.configuration({ environmentId: selectedThread.environmentId, input: { cwd: selectedThreadCwd }, @@ -448,7 +444,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { }) } /> - {isRepo && gitStatus.data?.vcs?.kind !== "jj" ? ( + {isRepo ? ( <> {Platform.OS !== "android" ? : null} assert.equal(failure._tag, "VcsUnsupportedOperationError"); }).pipe(Effect.provide(TestLayer)), ); + +describeJj("Jujutsu repository configuration", () => { + it.effect("raises the new-file snapshot limit and restores inheritance", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-jj-config-" }); + yield* createJjRepo(root); + const largeFile = `${root}/large.txt`; + yield* fileSystem.writeFileString(largeFile, "x".repeat(2 * 1024 * 1024)); + const before = yield* runJj(root, ["file", "list"]); + assert.isFalse(before.includes("large.txt")); + + const configuration = yield* VcsConfigurationService.VcsConfigurationService; + yield* configuration.write({ cwd: root, setting: "largeFile", value: "4" }); + const saved = yield* configuration.read({ cwd: root }); + assert.equal(saved.kind, "jj"); + assert.equal(saved.largeFile.repository, "4MiB"); + assert.isTrue((yield* runJj(root, ["file", "list"])).includes("large.txt")); + + yield* configuration.write({ cwd: root, setting: "largeFile", value: "0" }); + assert.equal((yield* configuration.read({ cwd: root })).largeFile.repository, "0"); + + yield* configuration.write({ cwd: root, setting: "largeFile", value: null }); + assert.equal((yield* configuration.read({ cwd: root })).largeFile.repository, null); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/vcs/VcsConfigurationService.ts b/apps/server/src/vcs/VcsConfigurationService.ts index a9f10a63f437..24a674997a9d 100644 --- a/apps/server/src/vcs/VcsConfigurationService.ts +++ b/apps/server/src/vcs/VcsConfigurationService.ts @@ -15,28 +15,47 @@ 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", +const CONFIG_KEYS: Record> = { + userName: { git: "user.name", jj: "user.name" }, + userEmail: { git: "user.email", jj: "user.email" }, + largeFile: { git: "core.bigFileThreshold", jj: "snapshot.max-new-file-size" }, }; +function keyFor(handle: Handle, setting: Setting): string { + return CONFIG_KEYS[setting][handle.kind === "jj" ? "jj" : "git"]; +} + +function parseRepositoryValue(value: string): string { + const trimmed = value.trim(); + try { + const parsed: unknown = JSON.parse(trimmed); + return typeof parsed === "string" || typeof parsed === "number" ? String(parsed) : trimmed; + } catch { + return trimmed; + } +} + function validateValue( setting: Setting, value: string, + kind: "git" | "jj", ): Effect.Effect { const trimmed = value.trim(); if (setting === "largeFile") { + if (kind === "jj" && trimmed === "0") return Effect.succeed("0"); 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.succeed(kind === "jj" ? `${mebibytes}MiB` : `${mebibytes}m`); } return Effect.fail( new VcsUnsupportedOperationError({ operation: "VcsConfigurationService.write", - kind: "git", - detail: "Enter a large-file threshold between 1 and 4096 MiB.", + kind, + detail: + kind === "jj" + ? "Enter a snapshot limit between 1 and 4096 MiB, or 0 for no limit." + : "Enter a large-file threshold between 1 and 4096 MiB.", }), ); } @@ -50,7 +69,7 @@ function validateValue( return Effect.fail( new VcsUnsupportedOperationError({ operation: "VcsConfigurationService.write", - kind: "git", + kind, detail: "Enter a name or email without control characters (up to 256 characters).", }), ); @@ -75,8 +94,13 @@ export const make = Effect.gen(function* () { setting: Setting, repositoryOnly: boolean, ) { - const key = CONFIG_KEYS[setting]; - const args = ["config", ...(repositoryOnly ? ["--local"] : []), "--get", key]; + const key = keyFor(handle, setting); + const isJj = handle.kind === "jj"; + const args = isJj + ? repositoryOnly + ? ["--ignore-working-copy", "config", "list", "--repo", key, "-T", "value"] + : ["--ignore-working-copy", "config", "get", key] + : ["config", ...(repositoryOnly ? ["--local"] : []), "--get", key]; const result = yield* handle.driver.execute({ operation: "VcsConfigurationService.read", cwd, @@ -84,29 +108,36 @@ export const make = Effect.gen(function* () { allowNonZeroExit: true, maxOutputBytes: 4_096, }); - if (result.exitCode === 1) return null; + if ( + result.exitCode === 1 && + (!isJj || + (repositoryOnly + ? result.stdout.trim() === "" + : result.stderr.includes("Value not found for"))) + ) + return null; if (result.exitCode !== 0) { return yield* new VcsProcessExitError({ operation: "VcsConfigurationService.read", - command: "git config", + command: isJj ? "jj config" : "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(); + return repositoryOnly && isJj ? parseRepositoryValue(result.stdout) : 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") { + if (handle.kind !== "git" && handle.kind !== "jj") { return yield* new VcsUnsupportedOperationError({ operation: "VcsConfigurationService.read", kind: handle.kind, - detail: "Repository configuration is available for Git only.", + detail: "Repository configuration is available for Git and Jujutsu only.", }); } const entry = (setting: Setting) => @@ -126,28 +157,39 @@ export const make = Effect.gen(function* () { "VcsConfigurationService.write", )(function* (input) { const handle = yield* registry.resolve({ cwd: input.cwd }); - if (handle.kind !== "git") { + if (handle.kind !== "git" && handle.kind !== "jj") { return yield* new VcsUnsupportedOperationError({ operation: "VcsConfigurationService.write", kind: handle.kind, - detail: "Repository configuration is available for Git only.", + detail: "Repository configuration is available for Git and Jujutsu only.", }); } - const key = CONFIG_KEYS[input.setting]; + const key = keyFor(handle, 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]), - ]; + const value = + input.value === null ? null : yield* validateValue(input.setting, input.value, handle.kind); + const args = + handle.kind === "jj" + ? [ + "--ignore-working-copy", + "config", + value === null ? "unset" : "set", + "--repo", + key, + ...(value === null ? [] : [value]), + ] + : [ + "config", + "--local", + value === null ? "--unset-all" : "--replace-all", + key, + ...(value === null ? [] : [value]), + ]; yield* handle.driver.execute({ operation: "VcsConfigurationService.write", cwd: input.cwd, diff --git a/apps/web/src/components/settings/VcsConfigurationSettings.tsx b/apps/web/src/components/settings/VcsConfigurationSettings.tsx index 69b712e778a0..233e240b91a3 100644 --- a/apps/web/src/components/settings/VcsConfigurationSettings.tsx +++ b/apps/web/src/components/settings/VcsConfigurationSettings.tsx @@ -85,16 +85,8 @@ export function VcsConfigurationSettingsSection() { (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" + member === null ? null : vcsEnvironment.configuration({ environmentId: member.environmentId, @@ -131,12 +123,12 @@ export function VcsConfigurationSettingsSection() { }; const config = configuration.data; - if (status.data?.vcs?.kind === "jj") return null; + const kind = config?.kind; return ( {member === null ? (

- Choose one Git checkout to edit its repository configuration. + Choose one checkout to edit its Git or Jujutsu configuration.

) : configuration.error ? (

{configuration.error}

@@ -167,8 +159,12 @@ export function VcsConfigurationSettingsSection() {