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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user