Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -448,7 +444,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
})
}
/>
{isRepo && gitStatus.data?.vcs?.kind !== "jj" ? (
{isRepo ? (
<>
{Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
<SheetListRow
Expand Down Expand Up @@ -482,8 +478,16 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
/>
<ConfigurationField
key={`${selectedThread?.environmentId}:${selectedThreadCwd}:large:${configuration.data.largeFile.repository}:${configuration.data.largeFile.effective}`}
label="Large file diff threshold"
detail="Git treats files above this size as binary in diffs. Enter MiB."
label={
configuration.data.kind === "jj"
? "New file snapshot limit"
: "Large file diff threshold"
}
detail={
configuration.data.kind === "jj"
? "Files above this size stay outside snapshots and checkpoints. Enter MiB, or 0 for no limit."
: "Git treats files above this size as binary in diffs. Enter MiB."
}
entry={configuration.data.largeFile}
setting="largeFile"
disabled={configurationSaving}
Expand Down
29 changes: 28 additions & 1 deletion apps/server/src/vcs/VcsConfigurationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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";
import { createJjRepo, describeJj, runGit, runJj } from "./testing/JjTestSupport.ts";

const TestLayer = VcsConfigurationService.layer.pipe(
Layer.provide(VcsDriverRegistry.layer),
Expand Down Expand Up @@ -41,3 +41,30 @@ it.effect("reads, writes, and resets guided Git repository configuration", () =>
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)),
);
});
94 changes: 68 additions & 26 deletions apps/server/src/vcs/VcsConfigurationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,47 @@ import * as VcsDriverRegistry from "./VcsDriverRegistry.ts";
type Setting = VcsConfigurationWriteInput["setting"];
type Handle = VcsDriverRegistry.VcsDriverHandle;

const CONFIG_KEYS: Record<Setting, string> = {
userName: "user.name",
userEmail: "user.email",
largeFile: "core.bigFileThreshold",
const CONFIG_KEYS: Record<Setting, Record<"git" | "jj", string>> = {
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<string, VcsUnsupportedOperationError> {
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.",
}),
);
}
Expand All @@ -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).",
}),
);
Expand All @@ -75,38 +94,50 @@ 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,
args,
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) =>
Expand All @@ -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,
Expand Down
22 changes: 9 additions & 13 deletions apps/web/src/components/settings/VcsConfigurationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -131,12 +123,12 @@ export function VcsConfigurationSettingsSection() {
};

const config = configuration.data;
if (status.data?.vcs?.kind === "jj") return null;
const kind = config?.kind;
return (
<SettingsSection title="Repository configuration">
{member === null ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
Choose one Git checkout to edit its repository configuration.
Choose one checkout to edit its Git or Jujutsu configuration.
</p>
) : configuration.error ? (
<p className="px-4 py-3 text-sm text-destructive">{configuration.error}</p>
Expand Down Expand Up @@ -167,8 +159,12 @@ export function VcsConfigurationSettingsSection() {
<ConfigurationRow
key={`${member.environmentId}:${member.workspaceRoot}:large:${config.largeFile.repository}:${config.largeFile.effective}`}
setting="largeFile"
title="Large file diff threshold"
description="Git treats files above this size as binary in diffs; checkpoints still include them. Enter a size in MiB."
title={kind === "jj" ? "New file snapshot limit" : "Large file diff threshold"}
description={
kind === "jj"
? "Files above this size stay outside Jujutsu snapshots and T3 checkpoints. Enter MiB, or 0 for no limit."
: "Git treats files above this size as binary in diffs; checkpoints still include them. Enter a size in MiB."
}
entry={config.largeFile}
disabled={saving}
onWrite={onWrite}
Expand Down
13 changes: 7 additions & 6 deletions docs/user/source-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +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
To configure one 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.
The guided controls set its commit author and email. For Jujutsu, the new-file snapshot limit
determines which files enter T3 checkpoints; for Git, the large-file threshold controls when Git
treats a file as binary in diffs. Git checkpoints include large files regardless of that threshold.
Reset removes a repository override and returns to the user's Git or Jujutsu configuration.

### What is different under Jujutsu

Expand All @@ -178,8 +179,8 @@ override and returns to the user's Git configuration.
- 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.
- Jujutsu refuses to snapshot a new file above `snapshot.max-new-file-size`, 1 MiB by default. That
file is outside checkpoints, so reverting a turn leaves it on disk. Raise the limit in your own
Jujutsu config if you want those files tracked.
file is outside checkpoints, so reverting a turn leaves it on disk. Raise the limit in repository
configuration if you want those files tracked.
- 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.
Expand Down
Loading