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 dd172580ed
commit 5777515bb3
11 changed files with 336 additions and 25 deletions

View File

@@ -488,8 +488,27 @@ export interface ApplyPushDeps {
* folder's `.md` at `refs/docmost/last-pushed`, SPEC §5 path-as-truth).
*/
git: Pick<VaultGit, "updateRef" | "fastForwardBranch" | "showFileAtRef">;
/**
* Per-space PUSH policy for a page body that still carries unresolved git
* conflict markers (SPEC §9). When TRUE, the marker lines are stripped and both
* sides' content is pushed (the legacy `stripConflictMarkers` behavior). When
* FALSE/undefined (the SAFE DEFAULT), the conflicted page is NOT pushed: it is
* recorded as a per-page FAILURE (so the refs are not advanced and the page is
* retried) and the user resolves the git conflict first.
*/
autoMergeConflicts?: boolean;
}
/**
* Reason recorded on a per-page push FAILURE when a page is skipped because its
* body still carries unresolved git conflict markers and `autoMergeConflicts` is
* off (the SAFE default). Recorded as a failure (not a soft skip) on purpose: it
* HOLDS the refs so the conflict commit is never marked as pushed and the page is
* retried until the human resolves the conflict in git (SPEC §9).
*/
export const CONFLICT_MARKERS_FAILURE_REASON =
"unresolved conflict markers — resolve in git first";
/** A file whose meta was rewritten with a freshly-assigned pageId (post-create). */
export interface WrittenBackPage {
path: string;
@@ -665,11 +684,22 @@ export async function applyPushActions(
// Push the CLEAN body only (no `gitmost_id` frontmatter): the frontmatter
// is engine metadata, never page content. The server converts the markdown
// it receives verbatim, so stripping here keeps the id out of Docmost.
// Also strip any git conflict markers — they must NEVER reach Docmost
// (SPEC §9, red-team #13); content on both sides is preserved.
const body = stripConflictMarkers(
parsePageFile(await deps.readFile(u.path)).body,
);
const rawBody = parsePageFile(await deps.readFile(u.path)).body;
// Git conflict markers must NEVER reach Docmost (SPEC §9, red-team #13).
// Per-space policy (`autoMergeConflicts`): when OFF (the SAFE default), a
// still-conflicted body is NOT pushed — record a failure so the refs are
// held and the page is retried once the human resolves the conflict in git.
// When ON, strip the marker lines and push both sides' content.
if (!deps.autoMergeConflicts && hasConflictMarkers(rawBody)) {
failures.push({
kind: "update",
pageId: u.pageId,
path: u.path,
error: CONFLICT_MARKERS_FAILURE_REASON,
});
continue;
}
const body = stripConflictMarkers(rawBody);
// The last-synced version of this file (pre-image) is the common ancestor
// for a 3-way merge against the live page, so concurrent human edits are
// not clobbered (review #5). Null when the file is new at last-pushed. Its
@@ -744,9 +774,21 @@ export async function applyPushActions(
for (const c of orderedCreates) {
try {
const text = await deps.readFile(c.path);
// Conflict markers must never reach Docmost (SPEC §9, red-team #13); strip
// them from the create body too, preserving both sides' content.
const body = stripConflictMarkers(parsePageFile(text).body);
const rawBody = parsePageFile(text).body;
// Conflict markers must never reach Docmost (SPEC §9, red-team #13). Honor
// the per-space `autoMergeConflicts` policy on the create path too: OFF (the
// SAFE default) records a failure (refs held, retried) rather than creating
// a page from conflicted content; ON strips the markers and pushes both
// sides' content.
if (!deps.autoMergeConflicts && hasConflictMarkers(rawBody)) {
failures.push({
kind: "create",
path: c.path,
error: CONFLICT_MARKERS_FAILURE_REASON,
});
continue;
}
const body = stripConflictMarkers(rawBody);
// Derive create args from the PATH (native-Obsidian, SPEC §5): title from
// the filename, parent from the enclosing folder's folder-note, space from
// the run (the vault's space). `parentPageId: null` -> created at ROOT.
@@ -1475,6 +1517,9 @@ export async function runPush(
readFile: deps.readFile,
writeFile: deps.writeFile,
spaceId: settings.docmostSpaceId,
// Per-space PUSH policy for still-conflicted bodies (SPEC §9). Default OFF:
// a conflicted page is skipped (recorded as a failure) instead of pushed.
autoMergeConflicts: settings.autoMergeConflicts ?? false,
},
actions,
pushedCommit,

View File

@@ -17,4 +17,15 @@ export type Settings = {
pollIntervalMs: number;
debounceMs: number;
logLevel: 'debug' | 'info' | 'warn' | 'error';
/**
* Per-space PUSH policy for a page whose committed body still contains
* unresolved git conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`):
* - false (DEFAULT, SAFE): SKIP that page's push (it is recorded as a push
* failure, so refs are NOT advanced) — the user must resolve the git
* conflict first before the page reaches Docmost.
* - true: strip the marker lines and push BOTH sides' content (the
* `stripConflictMarkers` behavior).
* Optional/undefined is treated as false.
*/
autoMergeConflicts?: boolean;
};

View File

@@ -24,9 +24,11 @@ function makeSettings(): Settings {
// ---------------------------------------------------------------------------
// #13 — conflict markers must never reach Docmost (SPEC §9), even when there is
// NO in-progress merge (markers committed on `main` by some other path). The
// push apply reads the body and hands it to importPageMarkdown verbatim; the
// DESIRED behavior is a content scan that prevents a `<<<<<<<` body from being
// pushed. Assert the pushed body does NOT contain a conflict marker.
// behavior is now gated by the per-space `autoMergeConflicts` setting:
// - DEFAULT (off): a still-conflicted page is NOT pushed — it is recorded as a
// per-page FAILURE and the refs are NOT advanced, so the user resolves the
// git conflict first.
// - ON: the marker lines are stripped and both sides' content is pushed.
// ---------------------------------------------------------------------------
function makePushGit(opts: {
changes: { status: 'A' | 'M' | 'D' | 'R' | 'C'; path: string; oldPath?: string }[];
@@ -60,11 +62,14 @@ function makePushGit(opts: {
}
describe('#13 conflict markers reach Docmost', () => {
it('does NOT push a body containing a `<<<<<<< HEAD` conflict marker', async () => {
const conflictBody =
'<<<<<<< HEAD\nmy line\n=======\ntheir line\n>>>>>>> feature\n';
const conflictBody =
'<<<<<<< HEAD\nmy line\n=======\ntheir line\n>>>>>>> feature\n';
function makeConflictDeps(settings: Settings) {
const file = serializePageFile('p-1', conflictBody);
const { git } = makePushGit({ changes: [{ status: 'M', path: 'Doc.md' }] });
const { git, calls } = makePushGit({
changes: [{ status: 'M', path: 'Doc.md' }],
});
const importPageMarkdown = vi.fn(async () => ({ success: true }));
const client = {
@@ -77,7 +82,7 @@ describe('#13 conflict markers reach Docmost', () => {
};
const deps: PushDeps = {
settings: makeSettings(),
settings,
git,
makeClient: () => client as any,
readFile: vi.fn(async (path: string) => {
@@ -87,6 +92,38 @@ describe('#13 conflict markers reach Docmost', () => {
writeFile: vi.fn(async () => {}),
log: () => {},
};
return { deps, importPageMarkdown, calls };
}
it('DEFAULT (autoMergeConflicts off): does NOT push a conflicted page; records a failure and holds the refs', async () => {
// makeSettings() leaves autoMergeConflicts undefined -> the SAFE default.
const { deps, importPageMarkdown, calls } = makeConflictDeps(makeSettings());
const res = await runPush(deps, { dryRun: false });
expect(res.mode).toBe('apply');
// The conflicted page is NOT pushed to Docmost at all.
expect(importPageMarkdown).not.toHaveBeenCalled();
// It is recorded as a per-page failure (so the user resolves the git conflict
// first), and because there is a failure the last-pushed ref is NOT advanced.
expect(res.applied?.failures).toEqual([
expect.objectContaining({
kind: 'update',
pageId: 'p-1',
path: 'Doc.md',
}),
]);
expect(res.applied?.failures[0].error).toMatch(/conflict markers/i);
expect(res.applied?.lastPushedAdvanced).toBe(false);
expect(calls.updateRef).toHaveLength(0);
});
it('autoMergeConflicts on: strips the markers and pushes a clean body', async () => {
const { deps, importPageMarkdown } = makeConflictDeps({
...makeSettings(),
autoMergeConflicts: true,
});
const res = await runPush(deps, { dryRun: false });
expect(res.mode).toBe('apply');
@@ -95,10 +132,12 @@ describe('#13 conflict markers reach Docmost', () => {
expect(importPageMarkdown).toHaveBeenCalledTimes(1);
const pushedBody: string = importPageMarkdown.mock.calls[0][1] as any;
// DESIRED: a content scan gates conflict markers; the body must be clean.
// The marker SYNTAX is stripped; both sides' content survives.
expect(pushedBody).not.toContain('<<<<<<<');
expect(pushedBody).not.toContain('=======');
expect(pushedBody).not.toContain('>>>>>>>');
expect(pushedBody).toContain('my line');
expect(pushedBody).toContain('their line');
});
});