feat(git-sync): per-space toggle for conflict-marker handling on push (#13)

Red-team #13 (conflict markers reaching Docmost) is now a per-space policy
exposed as a UI toggle, instead of a hardcoded behavior. New boolean
`gitSync.autoMergeConflicts` (default FALSE), mirroring the existing per-space
`gitSync.enabled` flag end-to-end (jsonb space settings -> update-space DTO ->
space.service -> client types -> space settings form switch):

- OFF (default, safe): a page whose committed body still has unresolved git
  conflict markers is NOT pushed — it is recorded as a per-page push FAILURE
  ("unresolved conflict markers — resolve in git first"). Recording a failure
  (not a soft skip) deliberately HOLDS refs/docmost/last-pushed so the conflict
  commit is never marked pushed and a later pull cannot clobber the user's
  in-progress resolution; the page retries until the conflict is resolved in git.
- ON: the marker lines are stripped and both sides' content is pushed (the prior
  behavior), so the conflict becomes visible/fixable inside Docmost.

The engine Settings carries `autoMergeConflicts`; runPush threads it into the
update AND create paths. The orchestrator's buildSettings reads the per-space
flag from jsonb (strict opt-in like `enabled`, default false).

Tests: redteam-push-cycle #13 rewritten (default -> not pushed + failure + refs
held; ON -> strip-and-push); space.service + edit-space-form + orchestrator
specs extended. git-sync vitest 618, server jest space+git-sync 163, client
edit-space-form 11, server/client tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-26 01:56:55 +03:00
parent e9c93fba5e
commit bd6aae11ff
11 changed files with 336 additions and 25 deletions

View File

@@ -80,13 +80,19 @@ function renderForm(props: { space: ISpace; readOnly?: boolean }) {
);
}
// The git-sync toggle is the only switch on the form. Mantine renders it as an
// <input type="checkbox" role="switch">; its label text lives in a sibling
// wrapper, so query by role and assert the visible label is present alongside.
// The form now renders TWO switches (git-sync enable + auto-merge-conflicts) in
// that DOM order. Mantine renders each as an <input type="checkbox"
// role="switch"> but does NOT expose its label as the accessible name, so we
// disambiguate by DOM order (index 0 = enable, 1 = auto-merge) and assert the
// human-readable label text is present alongside.
function getToggle(): HTMLInputElement {
// Sanity: the human-readable label is rendered.
screen.getByText("Enable Git sync");
return screen.getByRole("switch") as HTMLInputElement;
return screen.getAllByRole("switch")[0] as HTMLInputElement;
}
function getAutoMergeToggle(): HTMLInputElement {
screen.getByText("Auto-merge conflicts on push");
return screen.getAllByRole("switch")[1] as HTMLInputElement;
}
afterEach(() => {
@@ -169,3 +175,66 @@ describe("EditSpaceForm git-sync toggle", () => {
expect(getToggle().disabled).toBe(true);
});
});
describe("EditSpaceForm auto-merge-conflicts toggle", () => {
it("derives initial checked state from space.settings.gitSync.autoMergeConflicts (true -> checked)", () => {
renderForm({
space: makeSpace({
settings: { gitSync: { autoMergeConflicts: true } },
}),
});
expect(getAutoMergeToggle().checked).toBe(true);
});
it("defaults to unchecked when autoMergeConflicts is missing (SAFE default)", () => {
renderForm({ space: makeSpace() });
expect(getAutoMergeToggle().checked).toBe(false);
});
it("fires the mutation with { spaceId, autoMergeConflicts } and optimistically flips on", async () => {
mutateAsync.mockResolvedValue(undefined);
renderForm({ space: makeSpace() });
const toggle = getAutoMergeToggle();
expect(toggle.checked).toBe(false);
fireEvent.click(toggle);
// Optimistic update.
expect(toggle.checked).toBe(true);
expect(mutateAsync).toHaveBeenCalledTimes(1);
expect(mutateAsync).toHaveBeenCalledWith({
spaceId: "space-1",
autoMergeConflicts: true,
});
await waitFor(() => expect(toggle.checked).toBe(true));
});
it("rolls back to its prior state when the mutation rejects", async () => {
mutateAsync.mockRejectedValue(new Error("network"));
renderForm({
space: makeSpace({
settings: { gitSync: { autoMergeConflicts: false } },
}),
});
const toggle = getAutoMergeToggle();
expect(toggle.checked).toBe(false);
fireEvent.click(toggle);
expect(toggle.checked).toBe(true);
expect(mutateAsync).toHaveBeenCalledWith({
spaceId: "space-1",
autoMergeConflicts: true,
});
await waitFor(() => expect(toggle.checked).toBe(false));
});
it("disables the toggle when readOnly", () => {
renderForm({ space: makeSpace(), readOnly: true });
expect(getAutoMergeToggle().disabled).toBe(true);
});
});

View File

@@ -42,6 +42,10 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
space?.settings?.gitSync?.enabled ?? false,
);
const [autoMergeConflicts, setAutoMergeConflicts] = useState<boolean>(
space?.settings?.gitSync?.autoMergeConflicts ?? false,
);
const handleGitSyncToggle = async (value: boolean) => {
const previous = gitSyncEnabled;
setGitSyncEnabled(value); // optimistic update
@@ -58,6 +62,20 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
}
};
const handleAutoMergeConflictsToggle = async (value: boolean) => {
const previous = autoMergeConflicts;
setAutoMergeConflicts(value); // optimistic update
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
autoMergeConflicts: value,
});
} catch (err) {
setAutoMergeConflicts(previous); // revert on failure
console.error("Failed to toggle git-sync auto-merge-conflicts", err);
}
};
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
@@ -145,6 +163,19 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
handleGitSyncToggle(event.currentTarget.checked)
}
/>
<Switch
mt="md"
label={t("Auto-merge conflicts on push")}
description={t(
"When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.",
)}
checked={autoMergeConflicts}
disabled={readOnly || updateSpaceMutation.isPending}
onChange={(event) =>
handleAutoMergeConflictsToggle(event.currentTarget.checked)
}
/>
</Box>
</>
);

View File

@@ -15,6 +15,7 @@ export interface ISpaceCommentsSettings {
export interface ISpaceGitSyncSettings {
enabled?: boolean;
autoMergeConflicts?: boolean;
}
export interface ISpaceSettings {
@@ -41,6 +42,7 @@ export interface ISpace {
disablePublicSharing?: boolean;
allowViewerComments?: boolean;
gitSyncEnabled?: boolean;
autoMergeConflicts?: boolean;
}
interface IMembership {