FN-7086: default dirty checkout merge sync
Default AI merge landing to preserve legacy dirty-checkout synchronization for new and partially configured projects. - Default resolved project merger settings to allow dirty local checkout sync while preserving explicit false values. - Pass the resolved dirty-checkout sync policy through single-repo and workspace AI merge landing paths. - Update settings UI defaults, type docs, regression coverage, and release notes for the new behavior. Files changed: .changeset/fn-7086-allow-dirty-sync-default.md | 7 ++++ packages/core/src/__tests__/store-settings.test.ts | 40 ++++++++++++++++++++++ packages/core/src/settings-schema.ts | 6 +++- packages/core/src/store.ts | 12 +++++++ packages/core/src/types.ts | 6 ++-- .../dashboard/app/components/SettingsModal.tsx | 2 +- packages/engine/src/merger-ai.ts | 35 +++++++++++-------- 7 files changed, 89 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-7086 Fusion-Task-Lineage: 65871e7c-af0c-45b9-af54-749d56dfadd5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7086-allow-dirty-sync-default.md
Normal file
7
.changeset/fn-7086-allow-dirty-sync-default.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: New projects now default AI merge to sync a dirty checked-out integration branch.
|
||||
category: feature
|
||||
dev: Flips DEFAULT_PROJECT_SETTINGS merger.allowDirtyLocalCheckoutSync from false to true; explicit persisted values still win, with no existing-project migration.
|
||||
@@ -1663,6 +1663,46 @@ describe("TaskStore", () => {
|
||||
expect(scopedFast.project.ephemeralAgentsEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults merger.allowDirtyLocalCheckoutSync to true for new projects", async () => {
|
||||
const fast = await harness.store().getSettingsFast();
|
||||
const regular = await harness.store().getSettings();
|
||||
const scopedFast = await harness.store().getSettingsByScopeFast();
|
||||
|
||||
expect(fast.merger?.allowDirtyLocalCheckoutSync).toBe(true);
|
||||
expect(regular.merger?.allowDirtyLocalCheckoutSync).toBe(true);
|
||||
expect(scopedFast.project.merger?.allowDirtyLocalCheckoutSync).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to merger.allowDirtyLocalCheckoutSync=true when upgrading partial merger settings", async () => {
|
||||
const db = (harness.store() as any).db;
|
||||
const row = db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined;
|
||||
const existingSettings = row?.settings ? JSON.parse(row.settings) : {};
|
||||
existingSettings.merger = { mode: "ai", maxReviewPasses: 3 };
|
||||
db.prepare("UPDATE config SET settings = ? WHERE id = 1").run(JSON.stringify(existingSettings));
|
||||
|
||||
const fast = await harness.store().getSettingsFast();
|
||||
const regular = await harness.store().getSettings();
|
||||
const scopedFast = await harness.store().getSettingsByScopeFast();
|
||||
|
||||
expect(fast.merger?.allowDirtyLocalCheckoutSync).toBe(true);
|
||||
expect(regular.merger?.allowDirtyLocalCheckoutSync).toBe(true);
|
||||
expect(scopedFast.project.merger?.allowDirtyLocalCheckoutSync).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves explicit merger.allowDirtyLocalCheckoutSync=false", async () => {
|
||||
await harness.store().updateSettings({
|
||||
merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: false },
|
||||
});
|
||||
|
||||
const fast = await harness.store().getSettingsFast();
|
||||
const regular = await harness.store().getSettings();
|
||||
const scopedFast = await harness.store().getSettingsByScopeFast();
|
||||
|
||||
expect(fast.merger?.allowDirtyLocalCheckoutSync).toBe(false);
|
||||
expect(regular.merger?.allowDirtyLocalCheckoutSync).toBe(false);
|
||||
expect(scopedFast.project.merger?.allowDirtyLocalCheckoutSync).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the same merged result as getSettings()", async () => {
|
||||
await harness.store().updateGlobalSettings({ themeMode: "light", ntfyEnabled: true });
|
||||
await harness.store().updateSettings({ maxConcurrent: 5, autoMerge: false });
|
||||
|
||||
@@ -362,7 +362,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
* Project settings own the auto-merge conflict retry cap because existing engine/dashboard consumers already resolve project settings; the default imports core's stall-detection fallback to keep every surface on the historical value of 3.
|
||||
*/
|
||||
maxAutoMergeRetries: DEFAULT_MAX_AUTO_MERGE_RETRIES,
|
||||
merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: false },
|
||||
/**
|
||||
* FNXC:Merge 2026-06-26-00:00:
|
||||
* New and unconfigured projects default AI merge to sync a dirty checked-out integration branch, restoring the legacy stash → fast-forward → restore landing behavior. Explicit persisted merger.allowDirtyLocalCheckoutSync values still win, and no existing-project migration stamps this default into storage.
|
||||
*/
|
||||
merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: true },
|
||||
mergeDiffVolumeMinLines: undefined,
|
||||
mergeDiffVolumeThreshold: undefined,
|
||||
mergeDiffVolumeAllowlist: undefined,
|
||||
|
||||
@@ -3776,6 +3776,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
...DEFAULT_SETTINGS,
|
||||
...globalSettings,
|
||||
...projectSettings,
|
||||
/**
|
||||
* FNXC:Merge 2026-06-26-00:00:
|
||||
* The top-level settings spread is shallow, so legacy project rows with a partial merger object would otherwise drop nested defaults such as allowDirtyLocalCheckoutSync. Merge the nested default explicitly here and in fast/scoped reads, mirroring the worktrunk resolver and ephemeralAgentsEnabled upgrade fallback precedents.
|
||||
*/
|
||||
merger: { ...DEFAULT_PROJECT_SETTINGS.merger, ...(projectSettings as Partial<Settings>).merger },
|
||||
worktrunk: resolveWorktrunkSettings(
|
||||
globalSettings.worktrunk,
|
||||
(projectSettings as Partial<Settings>).worktrunk,
|
||||
@@ -3823,6 +3828,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
...DEFAULT_SETTINGS,
|
||||
...globalSettings,
|
||||
...projectSettings,
|
||||
merger: { ...DEFAULT_PROJECT_SETTINGS.merger, ...projectSettings?.merger },
|
||||
worktrunk: resolveWorktrunkSettings(globalSettings.worktrunk, projectSettings?.worktrunk),
|
||||
};
|
||||
try {
|
||||
@@ -3868,6 +3874,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
if (canonicalizedProject.ephemeralAgentsEnabled === undefined) {
|
||||
canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled;
|
||||
}
|
||||
if (canonicalizedProject.merger?.allowDirtyLocalCheckoutSync === undefined) {
|
||||
canonicalizedProject.merger = { ...DEFAULT_PROJECT_SETTINGS.merger, ...canonicalizedProject.merger };
|
||||
}
|
||||
|
||||
return { global: globalSettings, project: canonicalizedProject };
|
||||
}
|
||||
@@ -3912,6 +3921,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
if (canonicalizedProject.ephemeralAgentsEnabled === undefined) {
|
||||
canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled;
|
||||
}
|
||||
if (canonicalizedProject.merger?.allowDirtyLocalCheckoutSync === undefined) {
|
||||
canonicalizedProject.merger = { ...DEFAULT_PROJECT_SETTINGS.merger, ...canonicalizedProject.merger };
|
||||
}
|
||||
|
||||
return { global: globalSettings, project: canonicalizedProject };
|
||||
}
|
||||
|
||||
@@ -549,9 +549,9 @@ export interface MergerSettings {
|
||||
* validator/reviewer model lane — there is no merge-specific model setting. */
|
||||
maxReviewPasses?: number;
|
||||
/** Dangerous compatibility escape hatch for the AI merge landing path.
|
||||
* When false (default), Fusion refuses to land an AI merge if the checked-out
|
||||
* integration worktree is dirty. When true, restores the legacy stash →
|
||||
* fast-forward → restore behavior for operators who explicitly accept that
|
||||
* When true (default for resolved project settings), Fusion restores the legacy
|
||||
* stash → fast-forward → restore behavior when the checked-out integration
|
||||
* worktree is dirty. Set false to explicitly opt out and fail closed before
|
||||
* unrelated local edits can be reintroduced after landing. */
|
||||
allowDirtyLocalCheckoutSync?: boolean;
|
||||
}
|
||||
|
||||
@@ -729,7 +729,7 @@ export function SettingsModal({
|
||||
maxAutoMergeRetries: 3,
|
||||
mergeIntegrationWorktree: "reuse-task-worktree",
|
||||
mergeAdvanceAutoSync: "stash-and-ff",
|
||||
merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: false },
|
||||
merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: true },
|
||||
recycleWorktrees: false,
|
||||
executorAllowSiblingBranchRename: false,
|
||||
worktreeNaming: "random",
|
||||
|
||||
@@ -26,11 +26,10 @@
|
||||
* common path.
|
||||
* 4. CAS fast-forward of `refs/heads/<integration>` to the squash (retry on a
|
||||
* concurrent advance by rebuilding on the new tip).
|
||||
* 5. Sync the user's local checkout to the new tip only when it is clean by
|
||||
* default. Dirty checked-out integration worktrees fail closed before the
|
||||
* branch ref advances, preventing unrelated local changes from poisoning
|
||||
* subsequent merge runs. An explicit escape hatch can opt into the legacy
|
||||
* stash → ff → restore path.
|
||||
* 5. Sync the user's local checkout to the new tip. Resolved project settings
|
||||
* now default to the legacy dirty-checkout stash → ff → restore path, while
|
||||
* an explicit project opt-out can still fail closed before the branch ref
|
||||
* advances.
|
||||
*
|
||||
* Pure helpers (prompt builders, verdict parser) are exported for unit testing;
|
||||
* the orchestrator accepts injectable agent functions for the same reason.
|
||||
@@ -382,10 +381,11 @@ async function hasUnresolvedConflicts(cwd: string): Promise<boolean> {
|
||||
* checkout — `git merge --ff-only <squash>` (it moves both the branch ref
|
||||
* and the working tree). The user's real dirty state is read accurately
|
||||
* BEFORE the fast-forward (while HEAD === tipSha, so `git status` isn't
|
||||
* polluted by the ref move). By default a dirty checked-out integration
|
||||
* worktree is a hard blocker; callers must explicitly opt into stash/pop
|
||||
* reconciliation. If the checkout HEAD has already moved off tipSha,
|
||||
* that's a concurrent advance → rebuild.
|
||||
* polluted by the ref move). Project-resolved settings default to stash/pop
|
||||
* reconciliation for dirty integration checkouts, but this lower-level
|
||||
* helper still requires direct callers to opt in; otherwise dirty state is
|
||||
* a hard blocker. If the checkout HEAD has already moved off tipSha, that's
|
||||
* a concurrent advance → rebuild.
|
||||
*
|
||||
* B. The checkout is on a different branch (or the target isn't checked out
|
||||
* here). We advance the ref atomically via `update-ref` (CAS) and leave the
|
||||
@@ -405,9 +405,10 @@ export async function landSquash(input: {
|
||||
resolveConflicts?: (cwd: string, prompt: string) => Promise<void>;
|
||||
/**
|
||||
* Explicit escape hatch for callers that truly want Fusion to stash/pop real
|
||||
* local edits in the checked-out integration worktree. The default is false:
|
||||
* automation must not land a task while also manufacturing uncommitted local
|
||||
* state in the project root, because that poisons subsequent merge runs.
|
||||
* local edits in the checked-out integration worktree.
|
||||
*
|
||||
* FNXC:Merge 2026-06-26-00:00:
|
||||
* Resolved project settings default merger.allowDirtyLocalCheckoutSync to true for legacy operator UX, but this helper's parameter default intentionally remains false so direct/programmatic callers and tests fail closed unless they make the dirty-checkout sync policy explicit.
|
||||
*/
|
||||
allowDirtyLocalCheckoutSync?: boolean;
|
||||
}): Promise<LandResult> {
|
||||
@@ -855,6 +856,11 @@ export async function runAiMerge(
|
||||
const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit);
|
||||
const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt());
|
||||
const includeTaskId = settings.includeTaskIdInCommit !== false;
|
||||
/*
|
||||
* FNXC:Merge 2026-06-26-00:00:
|
||||
* runAiMerge callers may rely on already-resolved project settings instead of forwarding MergerOptions. Preserve an explicit option false, otherwise inherit merger.allowDirtyLocalCheckoutSync so new-project default true reaches both single-repo and workspace landing paths.
|
||||
*/
|
||||
const allowDirtyLocalCheckoutSync = options.allowDirtyLocalCheckoutSync ?? (settings.merger?.allowDirtyLocalCheckoutSync === true);
|
||||
// Trailers that link the squash commit to the board task (FN-id + lineage) and deterministic co-author attribution.
|
||||
const trailers = taskTrailers(taskId, task.lineageId, settings);
|
||||
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined;
|
||||
@@ -870,7 +876,7 @@ export async function runAiMerge(
|
||||
taskId, settings, audit, log, setStatus, maxPasses,
|
||||
mergeAgent, reviewAgent, stashResolveAgent,
|
||||
includeTaskId, trailers, taskTitle, signal: options.signal,
|
||||
allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true,
|
||||
allowDirtyLocalCheckoutSync,
|
||||
store,
|
||||
});
|
||||
|
||||
@@ -1107,6 +1113,7 @@ export async function landWorkspaceTask(
|
||||
const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit);
|
||||
const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt());
|
||||
const includeTaskId = settings.includeTaskIdInCommit !== false;
|
||||
const allowDirtyLocalCheckoutSync = options.allowDirtyLocalCheckoutSync ?? (settings.merger?.allowDirtyLocalCheckoutSync === true);
|
||||
const trailers = taskTrailers(taskId, task.lineageId, settings);
|
||||
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined;
|
||||
|
||||
@@ -1199,7 +1206,7 @@ export async function landWorkspaceTask(
|
||||
taskId, settings, audit, log, setStatus, maxPasses,
|
||||
mergeAgent, reviewAgent, stashResolveAgent,
|
||||
includeTaskId, trailers, taskTitle, signal: options.signal,
|
||||
allowDirtyLocalCheckoutSync: options.allowDirtyLocalCheckoutSync === true,
|
||||
allowDirtyLocalCheckoutSync,
|
||||
// FNXC:Workspace 2026-06-24-23:50: one sub-repo's dependency-sync failure must not block
|
||||
// landing the others — degrade verification for that repo, still land the git squash.
|
||||
nonFatalDependencySync: true,
|
||||
|
||||
Reference in New Issue
Block a user